@frameset/plex-player 1.0.6 → 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,1082 +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
- // Settings panel
206
- this.settingsPanel = document.createElement('div');
207
- this.settingsPanel.className = 'plex-settings-panel';
208
- this.settingsPanel.innerHTML = this._getSettingsHTML();
209
- this.container.appendChild(this.settingsPanel);
210
- }
211
-
212
- _getSettingsHTML() {
213
- const i18n = this.i18n;
214
- return `
215
- <div class="plex-settings-header">
216
- <span class="plex-settings-title">${i18n.settings || 'Settings'}</span>
217
- <button class="plex-settings-close">
218
- <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
219
- </button>
220
- </div>
221
- <div class="plex-settings-content">
222
- <div class="plex-settings-item" data-setting="speed">
223
- <span class="plex-settings-label">${i18n.speed || 'Speed'}</span>
224
- <span class="plex-settings-value">1x</span>
225
- </div>
226
- <div class="plex-settings-item" data-setting="quality">
227
- <span class="plex-settings-label">${i18n.quality || 'Quality'}</span>
228
- <span class="plex-settings-value">Auto</span>
229
- </div>
230
- <div class="plex-settings-item" data-setting="subtitles">
231
- <span class="plex-settings-label">${i18n.subtitles || 'Subtitles'}</span>
232
- <span class="plex-settings-value">${i18n.off || 'Off'}</span>
233
- </div>
234
- </div>
235
- <div class="plex-settings-submenu plex-settings-speed" style="display: none;">
236
- <div class="plex-settings-back">
237
- <svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
238
- <span>${i18n.speed || 'Speed'}</span>
239
- </div>
240
- <div class="plex-speed-options">
241
- <button class="plex-speed-option" data-speed="0.25">0.25x</button>
242
- <button class="plex-speed-option" data-speed="0.5">0.5x</button>
243
- <button class="plex-speed-option" data-speed="0.75">0.75x</button>
244
- <button class="plex-speed-option active" data-speed="1">1x</button>
245
- <button class="plex-speed-option" data-speed="1.25">1.25x</button>
246
- <button class="plex-speed-option" data-speed="1.5">1.5x</button>
247
- <button class="plex-speed-option" data-speed="1.75">1.75x</button>
248
- <button class="plex-speed-option" data-speed="2">2x</button>
249
- </div>
250
- </div>
251
- `;
252
- }
253
-
254
- _getControlsHTML() {
255
- const i18n = this.i18n;
256
- return `
257
- <div class="plex-progress-container">
258
- <div class="plex-progress-bar">
259
- <div class="plex-progress-buffered"></div>
260
- <div class="plex-progress-played"></div>
261
- <div class="plex-progress-handle"></div>
262
- </div>
263
- <div class="plex-progress-preview">
264
- <div class="plex-preview-time">0:00</div>
265
- </div>
266
- </div>
267
- <div class="plex-controls-bar">
268
- <div class="plex-controls-left">
269
- <button class="plex-btn plex-play-btn" title="${i18n.play || 'Play'}">
270
- <svg class="plex-icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
271
- <svg class="plex-icon-pause" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
272
- </button>
273
- <button class="plex-btn plex-prev-btn" title="${i18n.previous || 'Previous'}">
274
- <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
275
- </button>
276
- <button class="plex-btn plex-next-btn" title="${i18n.next || 'Next'}">
277
- <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
278
- </button>
279
- <div class="plex-volume-container">
280
- <button class="plex-btn plex-volume-btn" title="${i18n.mute || 'Mute'}">
281
- <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>
282
- <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>
283
- <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>
284
- </button>
285
- <div class="plex-volume-slider">
286
- <div class="plex-volume-track">
287
- <div class="plex-volume-level"></div>
288
- <div class="plex-volume-handle"></div>
289
- </div>
290
- </div>
291
- </div>
292
- <div class="plex-time">
293
- <span class="plex-time-current">0:00</span>
294
- <span class="plex-time-separator">/</span>
295
- <span class="plex-time-duration">0:00</span>
296
- </div>
297
- </div>
298
- <div class="plex-controls-right">
299
- <button class="plex-btn plex-settings-btn" title="${i18n.settings || 'Settings'}">
300
- <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>
301
- </button>
302
- ${this.options.pip ? `
303
- <button class="plex-btn plex-pip-btn" title="${i18n.pip || 'Picture-in-Picture'}">
304
- <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>
305
- </button>
306
- ` : ''}
307
- ${this.options.cast ? `
308
- <button class="plex-btn plex-cast-btn" title="${i18n.cast || 'Cast'}">
309
- <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>
310
- </button>
311
- ` : ''}
312
- ${this.options.fullscreen ? `
313
- <button class="plex-btn plex-fullscreen-btn" title="${i18n.fullscreen || 'Fullscreen'}">
314
- <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>
315
- <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>
316
- </button>
317
- ` : ''}
318
- </div>
319
- </div>
320
- `;
321
- }
322
-
323
- _cacheElements() {
324
- const $ = (sel) => this.controls.querySelector(sel);
325
-
326
- this.els = {
327
- progressContainer: $('.plex-progress-container'),
328
- progressBar: $('.plex-progress-bar'),
329
- progressBuffered: $('.plex-progress-buffered'),
330
- progressPlayed: $('.plex-progress-played'),
331
- progressHandle: $('.plex-progress-handle'),
332
- progressPreview: $('.plex-progress-preview'),
333
- previewTime: $('.plex-preview-time'),
334
- playBtn: $('.plex-play-btn'),
335
- prevBtn: $('.plex-prev-btn'),
336
- nextBtn: $('.plex-next-btn'),
337
- volumeBtn: $('.plex-volume-btn'),
338
- volumeSlider: $('.plex-volume-slider'),
339
- volumeTrack: $('.plex-volume-track'),
340
- volumeLevel: $('.plex-volume-level'),
341
- volumeHandle: $('.plex-volume-handle'),
342
- timeCurrent: $('.plex-time-current'),
343
- timeDuration: $('.plex-time-duration'),
344
- settingsBtn: $('.plex-settings-btn'),
345
- pipBtn: $('.plex-pip-btn'),
346
- castBtn: $('.plex-cast-btn'),
347
- fullscreenBtn: $('.plex-fullscreen-btn'),
348
- };
349
- }
350
-
351
- _bindEvents() {
352
- // Video events
353
- this.video.addEventListener('play', () => this._onPlay());
354
- this.video.addEventListener('pause', () => this._onPause());
355
- this.video.addEventListener('ended', () => this._onEnded());
356
- this.video.addEventListener('timeupdate', () => this._onTimeUpdate());
357
- this.video.addEventListener('progress', () => this._onProgress());
358
- this.video.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
359
- this.video.addEventListener('volumechange', () => this._onVolumeChange());
360
- this.video.addEventListener('waiting', () => this._onWaiting());
361
- this.video.addEventListener('canplay', () => this._onCanPlay());
362
- this.video.addEventListener('error', (e) => this._onError(e));
363
-
364
- // Control events
365
- this.bigPlayBtn.addEventListener('click', () => this.togglePlay());
366
- this.video.addEventListener('click', () => this.togglePlay());
367
-
368
- this.els.playBtn?.addEventListener('click', () => this.togglePlay());
369
- this.els.prevBtn?.addEventListener('click', () => this.previous());
370
- this.els.nextBtn?.addEventListener('click', () => this.next());
371
- this.els.volumeBtn?.addEventListener('click', () => this.toggleMute());
372
- this.els.pipBtn?.addEventListener('click', () => this.togglePiP());
373
- this.els.fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
374
- this.els.settingsBtn?.addEventListener('click', () => this.toggleSettings());
375
- this.els.castBtn?.addEventListener('click', () => this.cast());
376
-
377
- // Settings panel events
378
- this._bindSettingsEvents();
379
-
380
- // Progress bar
381
- this._bindProgressEvents();
382
-
383
- // Volume slider
384
- this._bindVolumeEvents();
385
-
386
- // Controls visibility
387
- this._bindControlsVisibility();
388
-
389
- // Fullscreen change
390
- document.addEventListener('fullscreenchange', () => this._onFullscreenChange());
391
- document.addEventListener('webkitfullscreenchange', () => this._onFullscreenChange());
392
- }
393
-
394
- _bindProgressEvents() {
395
- const progress = this.els.progressContainer;
396
- if (!progress) return;
397
-
398
- let isDragging = false;
399
-
400
- const seek = (e) => {
401
- const rect = this.els.progressBar.getBoundingClientRect();
402
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
403
- this.seekPercent(percent * 100);
404
- };
405
-
406
- const updatePreview = (e) => {
407
- const rect = this.els.progressBar.getBoundingClientRect();
408
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
409
- const time = percent * this.video.duration;
410
-
411
- if (this.els.previewTime) {
412
- this.els.previewTime.textContent = Utils.formatTime(time);
413
- }
414
- if (this.els.progressPreview) {
415
- const left = Utils.clamp(e.clientX - rect.left, 30, rect.width - 30);
416
- this.els.progressPreview.style.left = `${left}px`;
417
- }
418
- };
419
-
420
- progress.addEventListener('mousedown', (e) => {
421
- isDragging = true;
422
- seek(e);
423
- });
424
-
425
- progress.addEventListener('mousemove', (e) => {
426
- updatePreview(e);
427
- if (isDragging) seek(e);
428
- });
429
-
430
- document.addEventListener('mouseup', () => {
431
- isDragging = false;
432
- });
433
-
434
- document.addEventListener('mousemove', (e) => {
435
- if (isDragging) seek(e);
436
- });
437
- }
438
-
439
- _bindVolumeEvents() {
440
- const volumeTrack = this.els.volumeTrack;
441
- if (!volumeTrack) return;
442
-
443
- let isDragging = false;
444
-
445
- const setVolume = (e) => {
446
- const rect = volumeTrack.getBoundingClientRect();
447
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
448
- this.setVolume(percent);
449
- };
450
-
451
- volumeTrack.addEventListener('mousedown', (e) => {
452
- isDragging = true;
453
- setVolume(e);
454
- });
455
-
456
- document.addEventListener('mousemove', (e) => {
457
- if (isDragging) setVolume(e);
458
- });
459
-
460
- document.addEventListener('mouseup', () => {
461
- isDragging = false;
462
- });
463
- }
464
-
465
- _bindControlsVisibility() {
466
- const showControls = () => {
467
- this.container.classList.add('plex-controls-visible');
468
- clearTimeout(this._controlsTimeout);
469
- if (this._state.playing) {
470
- this._controlsTimeout = setTimeout(() => {
471
- this.container.classList.remove('plex-controls-visible');
472
- }, this.options.controlsHideDelay);
473
- }
474
- };
475
-
476
- this.container.addEventListener('mousemove', showControls);
477
- this.container.addEventListener('mouseenter', showControls);
478
- this.container.addEventListener('mouseleave', () => {
479
- if (this._state.playing) {
480
- this.container.classList.remove('plex-controls-visible');
481
- }
482
- });
483
-
484
- // Always show when paused
485
- this.video.addEventListener('pause', showControls);
486
- }
487
-
488
- // Event handlers
489
- _onPlay() {
490
- this._state.playing = true;
491
- this._state.paused = false;
492
- this.container.classList.add('plex-playing');
493
- this.container.classList.remove('plex-paused');
494
- this.bigPlayBtn.style.display = 'none';
495
- this._emit('play');
496
- }
497
-
498
- _onPause() {
499
- this._state.playing = false;
500
- this._state.paused = true;
501
- this.container.classList.remove('plex-playing');
502
- this.container.classList.add('plex-paused');
503
- this.bigPlayBtn.style.display = '';
504
- this._emit('pause');
505
- }
506
-
507
- _onEnded() {
508
- this._state.playing = false;
509
- this.container.classList.remove('plex-playing');
510
-
511
- // Auto-play next if playlist
512
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
513
- this.next();
514
- } else {
515
- this._emit('ended');
516
- }
517
- }
518
-
519
- _onTimeUpdate() {
520
- this._state.currentTime = this.video.currentTime;
521
- const percent = (this.video.currentTime / this.video.duration) * 100;
522
-
523
- if (this.els.progressPlayed) {
524
- this.els.progressPlayed.style.width = `${percent}%`;
525
- }
526
- if (this.els.progressHandle) {
527
- this.els.progressHandle.style.left = `${percent}%`;
528
- }
529
- if (this.els.timeCurrent) {
530
- this.els.timeCurrent.textContent = Utils.formatTime(this.video.currentTime);
531
- }
532
-
533
- this._emit('timeupdate', {
534
- currentTime: this.video.currentTime,
535
- duration: this.video.duration
536
- });
537
- }
538
-
539
- _onProgress() {
540
- const buffered = this.video.buffered;
541
- if (buffered.length > 0) {
542
- const percent = (buffered.end(buffered.length - 1) / this.video.duration) * 100;
543
- this._state.buffered = percent;
544
- if (this.els.progressBuffered) {
545
- this.els.progressBuffered.style.width = `${percent}%`;
546
- }
547
- this._emit('progress', { buffered: percent });
548
- }
549
- }
550
-
551
- _onLoadedMetadata() {
552
- this._state.duration = this.video.duration;
553
- if (this.els.timeDuration) {
554
- this.els.timeDuration.textContent = Utils.formatTime(this.video.duration);
555
- }
556
- this._emit('loadedmetadata', { duration: this.video.duration });
557
- }
558
-
559
- _onVolumeChange() {
560
- this._state.volume = this.video.volume;
561
- this._state.muted = this.video.muted;
562
-
563
- const volume = this.video.muted ? 0 : this.video.volume;
564
-
565
- // Update volume UI
566
- if (this.els.volumeLevel) {
567
- this.els.volumeLevel.style.width = `${volume * 100}%`;
568
- }
569
- if (this.els.volumeHandle) {
570
- this.els.volumeHandle.style.left = `${volume * 100}%`;
571
- }
572
-
573
- // Update icon
574
- this.container.classList.remove('plex-muted', 'plex-volume-low');
575
- if (this.video.muted || volume === 0) {
576
- this.container.classList.add('plex-muted');
577
- } else if (volume < 0.5) {
578
- this.container.classList.add('plex-volume-low');
579
- }
580
-
581
- this._emit('volumechange', { volume: this.video.volume, muted: this.video.muted });
582
- }
583
-
584
- _onWaiting() {
585
- this.container.classList.add('plex-loading');
586
- this._emit('waiting');
587
- }
588
-
589
- _onCanPlay() {
590
- this.container.classList.remove('plex-loading');
591
- this._emit('canplay');
592
- }
593
-
594
- _onError(e) {
595
- this._emit('error', {
596
- code: this.video.error?.code,
597
- message: this.video.error?.message
598
- });
599
- }
600
-
601
- _onFullscreenChange() {
602
- const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
603
- this._state.fullscreen = isFullscreen;
604
-
605
- if (isFullscreen) {
606
- this.container.classList.add('plex-fullscreen');
607
- } else {
608
- this.container.classList.remove('plex-fullscreen');
609
- }
610
-
611
- this._emit('fullscreenchange', { isFullscreen });
612
- }
613
-
614
- // Keyboard support
615
- _initKeyboard() {
616
- this._keyHandler = (e) => {
617
- if (!this.container.contains(document.activeElement) &&
618
- document.activeElement !== document.body) return;
619
-
620
- switch (e.key) {
621
- case ' ':
622
- case 'k':
623
- e.preventDefault();
624
- this.togglePlay();
625
- break;
626
- case 'ArrowLeft':
627
- e.preventDefault();
628
- this.seek(this.video.currentTime - 10);
629
- break;
630
- case 'ArrowRight':
631
- e.preventDefault();
632
- this.seek(this.video.currentTime + 10);
633
- break;
634
- case 'ArrowUp':
635
- e.preventDefault();
636
- this.setVolume(Math.min(1, this.video.volume + 0.1));
637
- break;
638
- case 'ArrowDown':
639
- e.preventDefault();
640
- this.setVolume(Math.max(0, this.video.volume - 0.1));
641
- break;
642
- case 'm':
643
- this.toggleMute();
644
- break;
645
- case 'f':
646
- this.toggleFullscreen();
647
- break;
648
- case 'p':
649
- this.togglePiP();
650
- break;
651
- case 'n':
652
- if (e.shiftKey) {
653
- this.previous();
654
- } else {
655
- this.next();
656
- }
657
- break;
658
- }
659
- };
660
- document.addEventListener('keydown', this._keyHandler);
661
- }
662
-
663
- // Touch support
664
- _initTouch() {
665
- let touchStartX = 0;
666
- let touchStartY = 0;
667
- let touchStartTime = 0;
668
-
669
- this.video.addEventListener('touchstart', (e) => {
670
- touchStartX = e.touches[0].clientX;
671
- touchStartY = e.touches[0].clientY;
672
- touchStartTime = Date.now();
673
- });
674
-
675
- this.video.addEventListener('touchend', (e) => {
676
- const touchEndX = e.changedTouches[0].clientX;
677
- const touchEndY = e.changedTouches[0].clientY;
678
- const touchDuration = Date.now() - touchStartTime;
679
-
680
- const deltaX = touchEndX - touchStartX;
681
- const deltaY = touchEndY - touchStartY;
682
-
683
- // Tap to toggle play (if quick tap)
684
- if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && touchDuration < 300) {
685
- this.togglePlay();
686
- }
687
- // Swipe to seek
688
- else if (Math.abs(deltaX) > 50 && Math.abs(deltaY) < 30) {
689
- if (deltaX > 0) {
690
- this.seek(this.video.currentTime + 10);
691
- } else {
692
- this.seek(this.video.currentTime - 10);
693
- }
694
- }
695
- });
696
- }
697
-
698
- // PiP support
699
- _initPiP() {
700
- if (!document.pictureInPictureEnabled) {
701
- if (this.els.pipBtn) {
702
- this.els.pipBtn.style.display = 'none';
703
- }
704
- return;
705
- }
706
-
707
- this.video.addEventListener('enterpictureinpicture', () => {
708
- this._state.pip = true;
709
- this.container.classList.add('plex-pip');
710
- this._emit('enterpip');
711
- });
712
-
713
- this.video.addEventListener('leavepictureinpicture', () => {
714
- this._state.pip = false;
715
- this.container.classList.remove('plex-pip');
716
- this._emit('leavepip');
717
- });
718
- }
719
-
720
- // Cast support
721
- _initCast() {
722
- // Chromecast requires HTTPS and specific browser
723
- const isChrome = /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent);
724
- const isHTTPS = location.protocol === 'https:' || location.hostname === 'localhost';
725
-
726
- if (!isChrome || !isHTTPS) {
727
- if (this.els.castBtn) {
728
- this.els.castBtn.style.display = 'none';
729
- }
730
- return;
731
- }
732
-
733
- // Load Cast SDK if available
734
- if (window.chrome && window.chrome.cast) {
735
- this._initCastApi();
736
- } else {
737
- window['__onGCastApiAvailable'] = (isAvailable) => {
738
- if (isAvailable) this._initCastApi();
739
- };
740
- }
741
- }
742
-
743
- _initCastApi() {
744
- if (this.els.castBtn) {
745
- this.els.castBtn.addEventListener('click', () => {
746
- if (window.cast && window.cast.framework) {
747
- const context = cast.framework.CastContext.getInstance();
748
- context.requestSession();
749
- }
750
- });
751
- }
752
- }
753
-
754
- // Event system
755
- on(event, callback) {
756
- if (!this._eventListeners.has(event)) {
757
- this._eventListeners.set(event, new Set());
758
- }
759
- this._eventListeners.get(event).add(callback);
760
- return this;
761
- }
762
-
763
- off(event, callback) {
764
- if (this._eventListeners.has(event)) {
765
- this._eventListeners.get(event).delete(callback);
766
- }
767
- return this;
768
- }
769
-
770
- _emit(event, data = {}) {
771
- if (this._eventListeners.has(event)) {
772
- this._eventListeners.get(event).forEach(callback => {
773
- try {
774
- callback(data);
775
- } catch (e) {
776
- console.error(`PlexPlayer: Error in ${event} handler:`, e);
777
- }
778
- });
779
- }
780
- }
781
-
782
- // Public API
783
- load(src, poster) {
784
- this.video.src = src;
785
- if (poster) this.video.poster = poster;
786
- this.video.load();
787
-
788
- if (this.options.autoplay) {
789
- this.play();
790
- }
791
- return this;
792
- }
793
-
794
- loadPlaylist(items) {
795
- this._playlist = items.map((item, index) => ({
796
- ...item,
797
- index
798
- }));
799
- this._currentIndex = 0;
800
-
801
- if (this._playlist.length > 0) {
802
- const first = this._playlist[0];
803
- this.load(first.src, first.poster);
804
- this._emit('playlistload', { playlist: this._playlist });
805
- }
806
- return this;
807
- }
808
-
809
- play() {
810
- return this.video.play();
811
- }
812
-
813
- pause() {
814
- this.video.pause();
815
- return this;
816
- }
817
-
818
- togglePlay() {
819
- if (this.video.paused) {
820
- this.play();
821
- } else {
822
- this.pause();
823
- }
824
- return this;
825
- }
826
-
827
- seek(time) {
828
- this.video.currentTime = Utils.clamp(time, 0, this.video.duration || 0);
829
- return this;
830
- }
831
-
832
- seekPercent(percent) {
833
- const time = (percent / 100) * this.video.duration;
834
- return this.seek(time);
835
- }
836
-
837
- setVolume(level) {
838
- this.video.volume = Utils.clamp(level, 0, 1);
839
- if (this.video.muted && level > 0) {
840
- this.video.muted = false;
841
- }
842
- return this;
843
- }
844
-
845
- getVolume() {
846
- return this.video.volume;
847
- }
848
-
849
- mute() {
850
- this.video.muted = true;
851
- return this;
852
- }
853
-
854
- unmute() {
855
- this.video.muted = false;
856
- return this;
857
- }
858
-
859
- toggleMute() {
860
- this.video.muted = !this.video.muted;
861
- return this;
862
- }
863
-
864
- setPlaybackRate(rate) {
865
- this.video.playbackRate = rate;
866
- this._state.playbackRate = rate;
867
- this._emit('ratechange', { rate });
868
- return this;
869
- }
870
-
871
- getPlaybackRate() {
872
- return this.video.playbackRate;
873
- }
874
-
875
- enterFullscreen() {
876
- const el = this.container;
877
- if (el.requestFullscreen) {
878
- return el.requestFullscreen();
879
- } else if (el.webkitRequestFullscreen) {
880
- return el.webkitRequestFullscreen();
881
- }
882
- }
883
-
884
- exitFullscreen() {
885
- if (document.exitFullscreen) {
886
- return document.exitFullscreen();
887
- } else if (document.webkitExitFullscreen) {
888
- return document.webkitExitFullscreen();
889
- }
890
- }
891
-
892
- toggleFullscreen() {
893
- if (this._state.fullscreen) {
894
- this.exitFullscreen();
895
- } else {
896
- this.enterFullscreen();
897
- }
898
- return this;
899
- }
900
-
901
- enterPiP() {
902
- if (document.pictureInPictureEnabled && this.video !== document.pictureInPictureElement) {
903
- return this.video.requestPictureInPicture();
904
- }
905
- }
906
-
907
- exitPiP() {
908
- if (document.pictureInPictureElement) {
909
- return document.exitPictureInPicture();
910
- }
911
- }
912
-
913
- togglePiP() {
914
- if (this._state.pip) {
915
- this.exitPiP();
916
- } else {
917
- this.enterPiP();
918
- }
919
- return this;
920
- }
921
-
922
- next() {
923
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
924
- this._currentIndex++;
925
- const item = this._playlist[this._currentIndex];
926
- this.load(item.src, item.poster);
927
- this.play();
928
- this._emit('trackchange', { index: this._currentIndex, item });
929
- }
930
- return this;
931
- }
932
-
933
- previous() {
934
- if (this._playlist.length > 0 && this._currentIndex > 0) {
935
- this._currentIndex--;
936
- const item = this._playlist[this._currentIndex];
937
- this.load(item.src, item.poster);
938
- this.play();
939
- this._emit('trackchange', { index: this._currentIndex, item });
940
- }
941
- return this;
942
- }
943
-
944
- playAt(index) {
945
- if (this._playlist.length > 0 && index >= 0 && index < this._playlist.length) {
946
- this._currentIndex = index;
947
- const item = this._playlist[this._currentIndex];
948
- this.load(item.src, item.poster);
949
- this.play();
950
- this._emit('trackchange', { index: this._currentIndex, item });
951
- }
952
- return this;
953
- }
954
-
955
- // Settings
956
- toggleSettings() {
957
- if (this.settingsPanel.classList.contains('plex-settings-open')) {
958
- this.closeSettings();
959
- } else {
960
- this.openSettings();
961
- }
962
- return this;
963
- }
964
-
965
- openSettings() {
966
- this.settingsPanel.classList.add('plex-settings-open');
967
- // Reset to main menu
968
- const content = this.settingsPanel.querySelector('.plex-settings-content');
969
- const speedMenu = this.settingsPanel.querySelector('.plex-settings-speed');
970
- if (content) content.style.display = 'block';
971
- if (speedMenu) speedMenu.style.display = 'none';
972
- return this;
973
- }
974
-
975
- closeSettings() {
976
- this.settingsPanel.classList.remove('plex-settings-open');
977
- return this;
978
- }
979
-
980
- _bindSettingsEvents() {
981
- if (!this.settingsPanel) return;
982
-
983
- // Close button
984
- const closeBtn = this.settingsPanel.querySelector('.plex-settings-close');
985
- closeBtn?.addEventListener('click', () => this.closeSettings());
986
-
987
- // Settings items
988
- const speedItem = this.settingsPanel.querySelector('[data-setting="speed"]');
989
- const speedMenu = this.settingsPanel.querySelector('.plex-settings-speed');
990
- const content = this.settingsPanel.querySelector('.plex-settings-content');
991
- const backBtn = this.settingsPanel.querySelector('.plex-settings-back');
992
-
993
- speedItem?.addEventListener('click', () => {
994
- if (content) content.style.display = 'none';
995
- if (speedMenu) speedMenu.style.display = 'block';
996
- });
997
-
998
- backBtn?.addEventListener('click', () => {
999
- if (content) content.style.display = 'block';
1000
- if (speedMenu) speedMenu.style.display = 'none';
1001
- });
1002
-
1003
- // Speed options
1004
- const speedOptions = this.settingsPanel.querySelectorAll('.plex-speed-option');
1005
- speedOptions.forEach(option => {
1006
- option.addEventListener('click', () => {
1007
- const speed = parseFloat(option.dataset.speed);
1008
- this.setPlaybackRate(speed);
1009
-
1010
- // Update active state
1011
- speedOptions.forEach(o => o.classList.remove('active'));
1012
- option.classList.add('active');
1013
-
1014
- // Update display
1015
- const speedValue = this.settingsPanel.querySelector('[data-setting="speed"] .plex-settings-value');
1016
- if (speedValue) speedValue.textContent = `${speed}x`;
1017
-
1018
- // Go back to main menu
1019
- if (content) content.style.display = 'block';
1020
- if (speedMenu) speedMenu.style.display = 'none';
1021
- });
1022
- });
1023
-
1024
- // Close settings when clicking outside
1025
- this.container.addEventListener('click', (e) => {
1026
- if (!this.settingsPanel.contains(e.target) &&
1027
- !this.els.settingsBtn?.contains(e.target) &&
1028
- this.settingsPanel.classList.contains('plex-settings-open')) {
1029
- this.closeSettings();
1030
- }
1031
- });
1032
- }
1033
-
1034
- getState() {
1035
- return {
1036
- currentTime: this.video.currentTime,
1037
- duration: this.video.duration || 0,
1038
- volume: this.video.volume,
1039
- muted: this.video.muted,
1040
- isPlaying: !this.video.paused && !this.video.ended,
1041
- isPaused: this.video.paused,
1042
- isFullscreen: this._state.fullscreen,
1043
- isPiP: this._state.pip,
1044
- playbackRate: this.video.playbackRate,
1045
- currentTrack: this._currentIndex,
1046
- totalTracks: this._playlist.length,
1047
- };
1048
- }
1049
-
1050
- getVideo() {
1051
- return this.video;
1052
- }
1053
-
1054
- destroy() {
1055
- // Clear timeouts
1056
- clearTimeout(this._controlsTimeout);
1057
-
1058
- // Remove keyboard listener
1059
- if (this._keyHandler) {
1060
- document.removeEventListener('keydown', this._keyHandler);
1061
- }
1062
-
1063
- // Remove event listeners
1064
- this._eventListeners.clear();
1065
-
1066
- // Clear container
1067
- this.container.innerHTML = '';
1068
- this.container.classList.remove('plex-player');
1069
-
1070
- // Emit destroy event
1071
- this._emit('destroy');
1072
- }
1073
- }
1074
-
1075
- // Export
1076
- export { PlexPlayer, Utils };
1077
- export default PlexPlayer;
1078
-
1079
- // Attach to window for UMD builds
1080
- if (typeof window !== 'undefined') {
1081
- window.PlexPlayer = PlexPlayer;
1082
- }