@frameset/plex-player 1.0.6 → 2.0.1

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.
@@ -1,1021 +0,0 @@
1
- /*!
2
- * @frameset/plex-player v1.0.6
3
- * Professional video player with VAST ads, Chromecast, PiP, subtitles, playlists and more. Built by FRAMESET Studio.
4
- * (c) 2026 FRAMESET Studio
5
- * Released under the MIT License
6
- * https://frameset.dev/plex-player
7
- */
8
- /**
9
- * @frameset/plex-player - Core Entry Point
10
- * Professional Video Player by FRAMESET Studio
11
- * https://frameset.dev
12
- */
13
-
14
- /**
15
- * Utility Functions
16
- */
17
- const Utils = {
18
- formatTime(seconds) {
19
- if (isNaN(seconds) || !isFinite(seconds)) return '0:00';
20
- seconds = Math.max(0, Math.floor(seconds));
21
- const hours = Math.floor(seconds / 3600);
22
- const minutes = Math.floor(seconds % 3600 / 60);
23
- const secs = seconds % 60;
24
- if (hours > 0) {
25
- return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
26
- }
27
- return `${minutes}:${secs.toString().padStart(2, '0')}`;
28
- },
29
- clamp(num, min, max) {
30
- return Math.min(Math.max(num, min), max);
31
- },
32
- throttle(fn, delay) {
33
- let lastCall = 0;
34
- return function (...args) {
35
- const now = Date.now();
36
- if (now - lastCall >= delay) {
37
- lastCall = now;
38
- return fn.apply(this, args);
39
- }
40
- };
41
- },
42
- debounce(fn, delay) {
43
- let timeoutId;
44
- return function (...args) {
45
- clearTimeout(timeoutId);
46
- timeoutId = setTimeout(() => fn.apply(this, args), delay);
47
- };
48
- }
49
- };
50
-
51
- /**
52
- * PlexPlayer - Main Player Class
53
- * A complete, standalone video player
54
- */
55
- class PlexPlayer {
56
- constructor(options = {}) {
57
- // Validate container
58
- if (!options.container) {
59
- throw new Error('PlexPlayer: container option is required');
60
- }
61
-
62
- // Get container element
63
- this.container = typeof options.container === 'string' ? document.querySelector(options.container) : options.container;
64
- if (!this.container) {
65
- throw new Error('PlexPlayer: container element not found');
66
- }
67
-
68
- // Store options with defaults
69
- this.options = {
70
- autoplay: false,
71
- muted: false,
72
- loop: false,
73
- volume: 1,
74
- poster: '',
75
- preload: 'metadata',
76
- keyboard: true,
77
- touch: true,
78
- pip: true,
79
- cast: true,
80
- fullscreen: true,
81
- controlsHideDelay: 3000,
82
- theme: {},
83
- subtitles: {},
84
- ads: {
85
- enabled: false
86
- },
87
- i18n: {},
88
- ...options
89
- };
90
-
91
- // Internationalization with defaults
92
- this.i18n = {
93
- play: 'Play',
94
- pause: 'Pause',
95
- mute: 'Mute',
96
- unmute: 'Unmute',
97
- fullscreen: 'Fullscreen',
98
- exitFullscreen: 'Exit Fullscreen',
99
- pip: 'Picture-in-Picture',
100
- exitPip: 'Exit Picture-in-Picture',
101
- settings: 'Settings',
102
- speed: 'Speed',
103
- quality: 'Quality',
104
- subtitles: 'Subtitles',
105
- off: 'Off',
106
- normal: 'Normal',
107
- captions: 'Captions',
108
- audio: 'Audio',
109
- volume: 'Volume',
110
- seekForward: 'Seek Forward',
111
- seekBackward: 'Seek Backward',
112
- ...this.options.i18n
113
- };
114
-
115
- // Internal state
116
- this._eventListeners = new Map();
117
- this._state = {
118
- playing: false,
119
- paused: true,
120
- muted: this.options.muted,
121
- volume: this.options.volume,
122
- currentTime: 0,
123
- duration: 0,
124
- buffered: 0,
125
- fullscreen: false,
126
- pip: false,
127
- playbackRate: 1
128
- };
129
-
130
- // Playlist state
131
- this._playlist = [];
132
- this._currentIndex = 0;
133
-
134
- // Controls timeout
135
- this._controlsTimeout = null;
136
-
137
- // Initialize player
138
- this._init();
139
- }
140
- _init() {
141
- // Add player class
142
- this.container.classList.add('plex-player');
143
- // Show controls initially
144
- this.container.classList.add('plex-controls-visible');
145
-
146
- // Apply theme
147
- this._applyTheme();
148
-
149
- // Create DOM structure
150
- this._createDOM();
151
-
152
- // Bind events
153
- this._bindEvents();
154
-
155
- // Initialize features
156
- if (this.options.keyboard) this._initKeyboard();
157
- if (this.options.touch) this._initTouch();
158
- if (this.options.pip) this._initPiP();
159
- if (this.options.cast) this._initCast();
160
-
161
- // Emit ready event
162
- setTimeout(() => this._emit('ready'), 0);
163
- }
164
- _applyTheme() {
165
- const {
166
- theme
167
- } = this.options;
168
- if (theme.primary) this.container.style.setProperty('--plex-primary', theme.primary);
169
- if (theme.background) this.container.style.setProperty('--plex-bg', theme.background);
170
- if (theme.text) this.container.style.setProperty('--plex-text', theme.text);
171
- if (theme.borderRadius) this.container.style.setProperty('--plex-border-radius', theme.borderRadius);
172
- }
173
- _createDOM() {
174
- // Video element
175
- this.video = document.createElement('video');
176
- this.video.className = 'plex-video';
177
- this.video.setAttribute('playsinline', '');
178
- this.video.setAttribute('webkit-playsinline', '');
179
- this.video.preload = this.options.preload;
180
- this.video.muted = this.options.muted;
181
- this.video.volume = this.options.volume;
182
- this.video.loop = this.options.loop;
183
- if (this.options.poster) this.video.poster = this.options.poster;
184
- this.container.appendChild(this.video);
185
-
186
- // Controls overlay
187
- this.controls = document.createElement('div');
188
- this.controls.className = 'plex-controls';
189
- this.controls.innerHTML = this._getControlsHTML();
190
- this.container.appendChild(this.controls);
191
-
192
- // Cache control elements
193
- this._cacheElements();
194
-
195
- // Big play button
196
- this.bigPlayBtn = document.createElement('div');
197
- this.bigPlayBtn.className = 'plex-big-play';
198
- this.bigPlayBtn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
199
- this.container.appendChild(this.bigPlayBtn);
200
-
201
- // Loading spinner
202
- this.loader = document.createElement('div');
203
- this.loader.className = 'plex-loader';
204
- this.loader.innerHTML = '<div class="plex-spinner"></div>';
205
- this.container.appendChild(this.loader);
206
-
207
- // Settings panel
208
- this.settingsPanel = document.createElement('div');
209
- this.settingsPanel.className = 'plex-settings-panel';
210
- this.settingsPanel.innerHTML = this._getSettingsHTML();
211
- this.container.appendChild(this.settingsPanel);
212
- }
213
- _getSettingsHTML() {
214
- const i18n = this.i18n;
215
- return `
216
- <div class="plex-settings-header">
217
- <span class="plex-settings-title">${i18n.settings || 'Settings'}</span>
218
- <button class="plex-settings-close">
219
- <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>
220
- </button>
221
- </div>
222
- <div class="plex-settings-content">
223
- <div class="plex-settings-item" data-setting="speed">
224
- <span class="plex-settings-label">${i18n.speed || 'Speed'}</span>
225
- <span class="plex-settings-value">1x</span>
226
- </div>
227
- <div class="plex-settings-item" data-setting="quality">
228
- <span class="plex-settings-label">${i18n.quality || 'Quality'}</span>
229
- <span class="plex-settings-value">Auto</span>
230
- </div>
231
- <div class="plex-settings-item" data-setting="subtitles">
232
- <span class="plex-settings-label">${i18n.subtitles || 'Subtitles'}</span>
233
- <span class="plex-settings-value">${i18n.off || 'Off'}</span>
234
- </div>
235
- </div>
236
- <div class="plex-settings-submenu plex-settings-speed" style="display: none;">
237
- <div class="plex-settings-back">
238
- <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>
239
- <span>${i18n.speed || 'Speed'}</span>
240
- </div>
241
- <div class="plex-speed-options">
242
- <button class="plex-speed-option" data-speed="0.25">0.25x</button>
243
- <button class="plex-speed-option" data-speed="0.5">0.5x</button>
244
- <button class="plex-speed-option" data-speed="0.75">0.75x</button>
245
- <button class="plex-speed-option active" data-speed="1">1x</button>
246
- <button class="plex-speed-option" data-speed="1.25">1.25x</button>
247
- <button class="plex-speed-option" data-speed="1.5">1.5x</button>
248
- <button class="plex-speed-option" data-speed="1.75">1.75x</button>
249
- <button class="plex-speed-option" data-speed="2">2x</button>
250
- </div>
251
- </div>
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
- _cacheElements() {
323
- const $ = sel => this.controls.querySelector(sel);
324
- this.els = {
325
- progressContainer: $('.plex-progress-container'),
326
- progressBar: $('.plex-progress-bar'),
327
- progressBuffered: $('.plex-progress-buffered'),
328
- progressPlayed: $('.plex-progress-played'),
329
- progressHandle: $('.plex-progress-handle'),
330
- progressPreview: $('.plex-progress-preview'),
331
- previewTime: $('.plex-preview-time'),
332
- playBtn: $('.plex-play-btn'),
333
- prevBtn: $('.plex-prev-btn'),
334
- nextBtn: $('.plex-next-btn'),
335
- volumeBtn: $('.plex-volume-btn'),
336
- volumeSlider: $('.plex-volume-slider'),
337
- volumeTrack: $('.plex-volume-track'),
338
- volumeLevel: $('.plex-volume-level'),
339
- volumeHandle: $('.plex-volume-handle'),
340
- timeCurrent: $('.plex-time-current'),
341
- timeDuration: $('.plex-time-duration'),
342
- settingsBtn: $('.plex-settings-btn'),
343
- pipBtn: $('.plex-pip-btn'),
344
- castBtn: $('.plex-cast-btn'),
345
- fullscreenBtn: $('.plex-fullscreen-btn')
346
- };
347
- }
348
- _bindEvents() {
349
- // Video events
350
- this.video.addEventListener('play', () => this._onPlay());
351
- this.video.addEventListener('pause', () => this._onPause());
352
- this.video.addEventListener('ended', () => this._onEnded());
353
- this.video.addEventListener('timeupdate', () => this._onTimeUpdate());
354
- this.video.addEventListener('progress', () => this._onProgress());
355
- this.video.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
356
- this.video.addEventListener('volumechange', () => this._onVolumeChange());
357
- this.video.addEventListener('waiting', () => this._onWaiting());
358
- this.video.addEventListener('canplay', () => this._onCanPlay());
359
- this.video.addEventListener('error', e => this._onError(e));
360
-
361
- // Control events
362
- this.bigPlayBtn.addEventListener('click', () => this.togglePlay());
363
- this.video.addEventListener('click', () => this.togglePlay());
364
- this.els.playBtn?.addEventListener('click', () => this.togglePlay());
365
- this.els.prevBtn?.addEventListener('click', () => this.previous());
366
- this.els.nextBtn?.addEventListener('click', () => this.next());
367
- this.els.volumeBtn?.addEventListener('click', () => this.toggleMute());
368
- this.els.pipBtn?.addEventListener('click', () => this.togglePiP());
369
- this.els.fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
370
- this.els.settingsBtn?.addEventListener('click', () => this.toggleSettings());
371
- this.els.castBtn?.addEventListener('click', () => this.cast());
372
-
373
- // Settings panel events
374
- this._bindSettingsEvents();
375
-
376
- // Progress bar
377
- this._bindProgressEvents();
378
-
379
- // Volume slider
380
- this._bindVolumeEvents();
381
-
382
- // Controls visibility
383
- this._bindControlsVisibility();
384
-
385
- // Fullscreen change
386
- document.addEventListener('fullscreenchange', () => this._onFullscreenChange());
387
- document.addEventListener('webkitfullscreenchange', () => this._onFullscreenChange());
388
- }
389
- _bindProgressEvents() {
390
- const progress = this.els.progressContainer;
391
- if (!progress) return;
392
- let isDragging = false;
393
- const seek = e => {
394
- const rect = this.els.progressBar.getBoundingClientRect();
395
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
396
- this.seekPercent(percent * 100);
397
- };
398
- const updatePreview = e => {
399
- const rect = this.els.progressBar.getBoundingClientRect();
400
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
401
- const time = percent * this.video.duration;
402
- if (this.els.previewTime) {
403
- this.els.previewTime.textContent = Utils.formatTime(time);
404
- }
405
- if (this.els.progressPreview) {
406
- const left = Utils.clamp(e.clientX - rect.left, 30, rect.width - 30);
407
- this.els.progressPreview.style.left = `${left}px`;
408
- }
409
- };
410
- progress.addEventListener('mousedown', e => {
411
- isDragging = true;
412
- seek(e);
413
- });
414
- progress.addEventListener('mousemove', e => {
415
- updatePreview(e);
416
- if (isDragging) seek(e);
417
- });
418
- document.addEventListener('mouseup', () => {
419
- isDragging = false;
420
- });
421
- document.addEventListener('mousemove', e => {
422
- if (isDragging) seek(e);
423
- });
424
- }
425
- _bindVolumeEvents() {
426
- const volumeTrack = this.els.volumeTrack;
427
- if (!volumeTrack) return;
428
- let isDragging = false;
429
- const setVolume = e => {
430
- const rect = volumeTrack.getBoundingClientRect();
431
- const percent = Utils.clamp((e.clientX - rect.left) / rect.width, 0, 1);
432
- this.setVolume(percent);
433
- };
434
- volumeTrack.addEventListener('mousedown', e => {
435
- isDragging = true;
436
- setVolume(e);
437
- });
438
- document.addEventListener('mousemove', e => {
439
- if (isDragging) setVolume(e);
440
- });
441
- document.addEventListener('mouseup', () => {
442
- isDragging = false;
443
- });
444
- }
445
- _bindControlsVisibility() {
446
- const showControls = () => {
447
- this.container.classList.add('plex-controls-visible');
448
- clearTimeout(this._controlsTimeout);
449
- if (this._state.playing) {
450
- this._controlsTimeout = setTimeout(() => {
451
- this.container.classList.remove('plex-controls-visible');
452
- }, this.options.controlsHideDelay);
453
- }
454
- };
455
- this.container.addEventListener('mousemove', showControls);
456
- this.container.addEventListener('mouseenter', showControls);
457
- this.container.addEventListener('mouseleave', () => {
458
- if (this._state.playing) {
459
- this.container.classList.remove('plex-controls-visible');
460
- }
461
- });
462
-
463
- // Always show when paused
464
- this.video.addEventListener('pause', showControls);
465
- }
466
-
467
- // Event handlers
468
- _onPlay() {
469
- this._state.playing = true;
470
- this._state.paused = false;
471
- this.container.classList.add('plex-playing');
472
- this.container.classList.remove('plex-paused');
473
- this.bigPlayBtn.style.display = 'none';
474
- this._emit('play');
475
- }
476
- _onPause() {
477
- this._state.playing = false;
478
- this._state.paused = true;
479
- this.container.classList.remove('plex-playing');
480
- this.container.classList.add('plex-paused');
481
- this.bigPlayBtn.style.display = '';
482
- this._emit('pause');
483
- }
484
- _onEnded() {
485
- this._state.playing = false;
486
- this.container.classList.remove('plex-playing');
487
-
488
- // Auto-play next if playlist
489
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
490
- this.next();
491
- } else {
492
- this._emit('ended');
493
- }
494
- }
495
- _onTimeUpdate() {
496
- this._state.currentTime = this.video.currentTime;
497
- const percent = this.video.currentTime / this.video.duration * 100;
498
- if (this.els.progressPlayed) {
499
- this.els.progressPlayed.style.width = `${percent}%`;
500
- }
501
- if (this.els.progressHandle) {
502
- this.els.progressHandle.style.left = `${percent}%`;
503
- }
504
- if (this.els.timeCurrent) {
505
- this.els.timeCurrent.textContent = Utils.formatTime(this.video.currentTime);
506
- }
507
- this._emit('timeupdate', {
508
- currentTime: this.video.currentTime,
509
- duration: this.video.duration
510
- });
511
- }
512
- _onProgress() {
513
- const buffered = this.video.buffered;
514
- if (buffered.length > 0) {
515
- const percent = buffered.end(buffered.length - 1) / this.video.duration * 100;
516
- this._state.buffered = percent;
517
- if (this.els.progressBuffered) {
518
- this.els.progressBuffered.style.width = `${percent}%`;
519
- }
520
- this._emit('progress', {
521
- buffered: percent
522
- });
523
- }
524
- }
525
- _onLoadedMetadata() {
526
- this._state.duration = this.video.duration;
527
- if (this.els.timeDuration) {
528
- this.els.timeDuration.textContent = Utils.formatTime(this.video.duration);
529
- }
530
- this._emit('loadedmetadata', {
531
- duration: this.video.duration
532
- });
533
- }
534
- _onVolumeChange() {
535
- this._state.volume = this.video.volume;
536
- this._state.muted = this.video.muted;
537
- const volume = this.video.muted ? 0 : this.video.volume;
538
-
539
- // Update volume UI
540
- if (this.els.volumeLevel) {
541
- this.els.volumeLevel.style.width = `${volume * 100}%`;
542
- }
543
- if (this.els.volumeHandle) {
544
- this.els.volumeHandle.style.left = `${volume * 100}%`;
545
- }
546
-
547
- // Update icon
548
- this.container.classList.remove('plex-muted', 'plex-volume-low');
549
- if (this.video.muted || volume === 0) {
550
- this.container.classList.add('plex-muted');
551
- } else if (volume < 0.5) {
552
- this.container.classList.add('plex-volume-low');
553
- }
554
- this._emit('volumechange', {
555
- volume: this.video.volume,
556
- muted: this.video.muted
557
- });
558
- }
559
- _onWaiting() {
560
- this.container.classList.add('plex-loading');
561
- this._emit('waiting');
562
- }
563
- _onCanPlay() {
564
- this.container.classList.remove('plex-loading');
565
- this._emit('canplay');
566
- }
567
- _onError(e) {
568
- this._emit('error', {
569
- code: this.video.error?.code,
570
- message: this.video.error?.message
571
- });
572
- }
573
- _onFullscreenChange() {
574
- const isFullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement);
575
- this._state.fullscreen = isFullscreen;
576
- if (isFullscreen) {
577
- this.container.classList.add('plex-fullscreen');
578
- } else {
579
- this.container.classList.remove('plex-fullscreen');
580
- }
581
- this._emit('fullscreenchange', {
582
- isFullscreen
583
- });
584
- }
585
-
586
- // Keyboard support
587
- _initKeyboard() {
588
- this._keyHandler = e => {
589
- if (!this.container.contains(document.activeElement) && document.activeElement !== document.body) return;
590
- switch (e.key) {
591
- case ' ':
592
- case 'k':
593
- e.preventDefault();
594
- this.togglePlay();
595
- break;
596
- case 'ArrowLeft':
597
- e.preventDefault();
598
- this.seek(this.video.currentTime - 10);
599
- break;
600
- case 'ArrowRight':
601
- e.preventDefault();
602
- this.seek(this.video.currentTime + 10);
603
- break;
604
- case 'ArrowUp':
605
- e.preventDefault();
606
- this.setVolume(Math.min(1, this.video.volume + 0.1));
607
- break;
608
- case 'ArrowDown':
609
- e.preventDefault();
610
- this.setVolume(Math.max(0, this.video.volume - 0.1));
611
- break;
612
- case 'm':
613
- this.toggleMute();
614
- break;
615
- case 'f':
616
- this.toggleFullscreen();
617
- break;
618
- case 'p':
619
- this.togglePiP();
620
- break;
621
- case 'n':
622
- if (e.shiftKey) {
623
- this.previous();
624
- } else {
625
- this.next();
626
- }
627
- break;
628
- }
629
- };
630
- document.addEventListener('keydown', this._keyHandler);
631
- }
632
-
633
- // Touch support
634
- _initTouch() {
635
- let touchStartX = 0;
636
- let touchStartY = 0;
637
- let touchStartTime = 0;
638
- this.video.addEventListener('touchstart', e => {
639
- touchStartX = e.touches[0].clientX;
640
- touchStartY = e.touches[0].clientY;
641
- touchStartTime = Date.now();
642
- });
643
- this.video.addEventListener('touchend', e => {
644
- const touchEndX = e.changedTouches[0].clientX;
645
- const touchEndY = e.changedTouches[0].clientY;
646
- const touchDuration = Date.now() - touchStartTime;
647
- const deltaX = touchEndX - touchStartX;
648
- const deltaY = touchEndY - touchStartY;
649
-
650
- // Tap to toggle play (if quick tap)
651
- if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && touchDuration < 300) {
652
- this.togglePlay();
653
- }
654
- // Swipe to seek
655
- else if (Math.abs(deltaX) > 50 && Math.abs(deltaY) < 30) {
656
- if (deltaX > 0) {
657
- this.seek(this.video.currentTime + 10);
658
- } else {
659
- this.seek(this.video.currentTime - 10);
660
- }
661
- }
662
- });
663
- }
664
-
665
- // PiP support
666
- _initPiP() {
667
- if (!document.pictureInPictureEnabled) {
668
- if (this.els.pipBtn) {
669
- this.els.pipBtn.style.display = 'none';
670
- }
671
- return;
672
- }
673
- this.video.addEventListener('enterpictureinpicture', () => {
674
- this._state.pip = true;
675
- this.container.classList.add('plex-pip');
676
- this._emit('enterpip');
677
- });
678
- this.video.addEventListener('leavepictureinpicture', () => {
679
- this._state.pip = false;
680
- this.container.classList.remove('plex-pip');
681
- this._emit('leavepip');
682
- });
683
- }
684
-
685
- // Cast support
686
- _initCast() {
687
- // Chromecast requires HTTPS and specific browser
688
- const isChrome = /Chrome/.test(navigator.userAgent) && !/Edge/.test(navigator.userAgent);
689
- const isHTTPS = location.protocol === 'https:' || location.hostname === 'localhost';
690
- if (!isChrome || !isHTTPS) {
691
- if (this.els.castBtn) {
692
- this.els.castBtn.style.display = 'none';
693
- }
694
- return;
695
- }
696
-
697
- // Load Cast SDK if available
698
- if (window.chrome && window.chrome.cast) {
699
- this._initCastApi();
700
- } else {
701
- window['__onGCastApiAvailable'] = isAvailable => {
702
- if (isAvailable) this._initCastApi();
703
- };
704
- }
705
- }
706
- _initCastApi() {
707
- if (this.els.castBtn) {
708
- this.els.castBtn.addEventListener('click', () => {
709
- if (window.cast && window.cast.framework) {
710
- const context = cast.framework.CastContext.getInstance();
711
- context.requestSession();
712
- }
713
- });
714
- }
715
- }
716
-
717
- // Event system
718
- on(event, callback) {
719
- if (!this._eventListeners.has(event)) {
720
- this._eventListeners.set(event, new Set());
721
- }
722
- this._eventListeners.get(event).add(callback);
723
- return this;
724
- }
725
- off(event, callback) {
726
- if (this._eventListeners.has(event)) {
727
- this._eventListeners.get(event).delete(callback);
728
- }
729
- return this;
730
- }
731
- _emit(event, data = {}) {
732
- if (this._eventListeners.has(event)) {
733
- this._eventListeners.get(event).forEach(callback => {
734
- try {
735
- callback(data);
736
- } catch (e) {
737
- console.error(`PlexPlayer: Error in ${event} handler:`, e);
738
- }
739
- });
740
- }
741
- }
742
-
743
- // Public API
744
- load(src, poster) {
745
- this.video.src = src;
746
- if (poster) this.video.poster = poster;
747
- this.video.load();
748
- if (this.options.autoplay) {
749
- this.play();
750
- }
751
- return this;
752
- }
753
- loadPlaylist(items) {
754
- this._playlist = items.map((item, index) => ({
755
- ...item,
756
- index
757
- }));
758
- this._currentIndex = 0;
759
- if (this._playlist.length > 0) {
760
- const first = this._playlist[0];
761
- this.load(first.src, first.poster);
762
- this._emit('playlistload', {
763
- playlist: this._playlist
764
- });
765
- }
766
- return this;
767
- }
768
- play() {
769
- return this.video.play();
770
- }
771
- pause() {
772
- this.video.pause();
773
- return this;
774
- }
775
- togglePlay() {
776
- if (this.video.paused) {
777
- this.play();
778
- } else {
779
- this.pause();
780
- }
781
- return this;
782
- }
783
- seek(time) {
784
- this.video.currentTime = Utils.clamp(time, 0, this.video.duration || 0);
785
- return this;
786
- }
787
- seekPercent(percent) {
788
- const time = percent / 100 * this.video.duration;
789
- return this.seek(time);
790
- }
791
- setVolume(level) {
792
- this.video.volume = Utils.clamp(level, 0, 1);
793
- if (this.video.muted && level > 0) {
794
- this.video.muted = false;
795
- }
796
- return this;
797
- }
798
- getVolume() {
799
- return this.video.volume;
800
- }
801
- mute() {
802
- this.video.muted = true;
803
- return this;
804
- }
805
- unmute() {
806
- this.video.muted = false;
807
- return this;
808
- }
809
- toggleMute() {
810
- this.video.muted = !this.video.muted;
811
- return this;
812
- }
813
- setPlaybackRate(rate) {
814
- this.video.playbackRate = rate;
815
- this._state.playbackRate = rate;
816
- this._emit('ratechange', {
817
- rate
818
- });
819
- return this;
820
- }
821
- getPlaybackRate() {
822
- return this.video.playbackRate;
823
- }
824
- enterFullscreen() {
825
- const el = this.container;
826
- if (el.requestFullscreen) {
827
- return el.requestFullscreen();
828
- } else if (el.webkitRequestFullscreen) {
829
- return el.webkitRequestFullscreen();
830
- }
831
- }
832
- exitFullscreen() {
833
- if (document.exitFullscreen) {
834
- return document.exitFullscreen();
835
- } else if (document.webkitExitFullscreen) {
836
- return document.webkitExitFullscreen();
837
- }
838
- }
839
- toggleFullscreen() {
840
- if (this._state.fullscreen) {
841
- this.exitFullscreen();
842
- } else {
843
- this.enterFullscreen();
844
- }
845
- return this;
846
- }
847
- enterPiP() {
848
- if (document.pictureInPictureEnabled && this.video !== document.pictureInPictureElement) {
849
- return this.video.requestPictureInPicture();
850
- }
851
- }
852
- exitPiP() {
853
- if (document.pictureInPictureElement) {
854
- return document.exitPictureInPicture();
855
- }
856
- }
857
- togglePiP() {
858
- if (this._state.pip) {
859
- this.exitPiP();
860
- } else {
861
- this.enterPiP();
862
- }
863
- return this;
864
- }
865
- next() {
866
- if (this._playlist.length > 0 && this._currentIndex < this._playlist.length - 1) {
867
- this._currentIndex++;
868
- const item = this._playlist[this._currentIndex];
869
- this.load(item.src, item.poster);
870
- this.play();
871
- this._emit('trackchange', {
872
- index: this._currentIndex,
873
- item
874
- });
875
- }
876
- return this;
877
- }
878
- previous() {
879
- if (this._playlist.length > 0 && this._currentIndex > 0) {
880
- this._currentIndex--;
881
- const item = this._playlist[this._currentIndex];
882
- this.load(item.src, item.poster);
883
- this.play();
884
- this._emit('trackchange', {
885
- index: this._currentIndex,
886
- item
887
- });
888
- }
889
- return this;
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', {
898
- index: this._currentIndex,
899
- item
900
- });
901
- }
902
- return this;
903
- }
904
-
905
- // Settings
906
- toggleSettings() {
907
- if (this.settingsPanel.classList.contains('plex-settings-open')) {
908
- this.closeSettings();
909
- } else {
910
- this.openSettings();
911
- }
912
- return this;
913
- }
914
- openSettings() {
915
- this.settingsPanel.classList.add('plex-settings-open');
916
- // Reset to main menu
917
- const content = this.settingsPanel.querySelector('.plex-settings-content');
918
- const speedMenu = this.settingsPanel.querySelector('.plex-settings-speed');
919
- if (content) content.style.display = 'block';
920
- if (speedMenu) speedMenu.style.display = 'none';
921
- return this;
922
- }
923
- closeSettings() {
924
- this.settingsPanel.classList.remove('plex-settings-open');
925
- return this;
926
- }
927
- _bindSettingsEvents() {
928
- if (!this.settingsPanel) return;
929
-
930
- // Close button
931
- const closeBtn = this.settingsPanel.querySelector('.plex-settings-close');
932
- closeBtn?.addEventListener('click', () => this.closeSettings());
933
-
934
- // Settings items
935
- const speedItem = this.settingsPanel.querySelector('[data-setting="speed"]');
936
- const speedMenu = this.settingsPanel.querySelector('.plex-settings-speed');
937
- const content = this.settingsPanel.querySelector('.plex-settings-content');
938
- const backBtn = this.settingsPanel.querySelector('.plex-settings-back');
939
- speedItem?.addEventListener('click', () => {
940
- if (content) content.style.display = 'none';
941
- if (speedMenu) speedMenu.style.display = 'block';
942
- });
943
- backBtn?.addEventListener('click', () => {
944
- if (content) content.style.display = 'block';
945
- if (speedMenu) speedMenu.style.display = 'none';
946
- });
947
-
948
- // Speed options
949
- const speedOptions = this.settingsPanel.querySelectorAll('.plex-speed-option');
950
- speedOptions.forEach(option => {
951
- option.addEventListener('click', () => {
952
- const speed = parseFloat(option.dataset.speed);
953
- this.setPlaybackRate(speed);
954
-
955
- // Update active state
956
- speedOptions.forEach(o => o.classList.remove('active'));
957
- option.classList.add('active');
958
-
959
- // Update display
960
- const speedValue = this.settingsPanel.querySelector('[data-setting="speed"] .plex-settings-value');
961
- if (speedValue) speedValue.textContent = `${speed}x`;
962
-
963
- // Go back to main menu
964
- if (content) content.style.display = 'block';
965
- if (speedMenu) speedMenu.style.display = 'none';
966
- });
967
- });
968
-
969
- // Close settings when clicking outside
970
- this.container.addEventListener('click', e => {
971
- if (!this.settingsPanel.contains(e.target) && !this.els.settingsBtn?.contains(e.target) && this.settingsPanel.classList.contains('plex-settings-open')) {
972
- this.closeSettings();
973
- }
974
- });
975
- }
976
- getState() {
977
- return {
978
- currentTime: this.video.currentTime,
979
- duration: this.video.duration || 0,
980
- volume: this.video.volume,
981
- muted: this.video.muted,
982
- isPlaying: !this.video.paused && !this.video.ended,
983
- isPaused: this.video.paused,
984
- isFullscreen: this._state.fullscreen,
985
- isPiP: this._state.pip,
986
- playbackRate: this.video.playbackRate,
987
- currentTrack: this._currentIndex,
988
- totalTracks: this._playlist.length
989
- };
990
- }
991
- getVideo() {
992
- return this.video;
993
- }
994
- destroy() {
995
- // Clear timeouts
996
- clearTimeout(this._controlsTimeout);
997
-
998
- // Remove keyboard listener
999
- if (this._keyHandler) {
1000
- document.removeEventListener('keydown', this._keyHandler);
1001
- }
1002
-
1003
- // Remove event listeners
1004
- this._eventListeners.clear();
1005
-
1006
- // Clear container
1007
- this.container.innerHTML = '';
1008
- this.container.classList.remove('plex-player');
1009
-
1010
- // Emit destroy event
1011
- this._emit('destroy');
1012
- }
1013
- }
1014
-
1015
- // Attach to window for UMD builds
1016
- if (typeof window !== 'undefined') {
1017
- window.PlexPlayer = PlexPlayer;
1018
- }
1019
-
1020
- export { PlexPlayer, Utils, PlexPlayer as default };
1021
- //# sourceMappingURL=plex-player.esm.js.map