myetv-player 1.0.0 → 1.0.8
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/.github/workflows/codeql.yml +100 -0
- package/README.md +49 -58
- package/SECURITY.md +50 -0
- package/css/myetv-player.css +424 -219
- package/css/myetv-player.min.css +1 -1
- package/dist/myetv-player.js +1759 -1502
- package/dist/myetv-player.min.js +1705 -1469
- package/package.json +7 -1
- package/plugins/README.md +1016 -0
- package/plugins/cloudflare/README.md +1068 -0
- package/plugins/cloudflare/myetv-player-cloudflare-stream-plugin.js +556 -0
- package/plugins/facebook/README.md +1024 -0
- package/plugins/facebook/myetv-player-facebook-plugin.js +437 -0
- package/plugins/gamepad-remote-controller/README.md +816 -0
- package/plugins/gamepad-remote-controller/myetv-player-gamepad-remote-plugin.js +678 -0
- package/plugins/google-adsense-ads/README.md +1 -0
- package/plugins/google-adsense-ads/g-adsense-ads-plugin.js +158 -0
- package/plugins/google-ima-ads/README.md +1 -0
- package/plugins/google-ima-ads/g-ima-ads-plugin.js +355 -0
- package/plugins/twitch/README.md +1185 -0
- package/plugins/twitch/myetv-player-twitch-plugin.js +569 -0
- package/plugins/vast-vpaid-ads/README.md +1 -0
- package/plugins/vast-vpaid-ads/vast-vpaid-ads-plugin.js +346 -0
- package/plugins/vimeo/README.md +1416 -0
- package/plugins/vimeo/myetv-player-vimeo.js +640 -0
- package/plugins/youtube/README.md +851 -0
- package/plugins/youtube/myetv-player-youtube-plugin.js +1714 -210
- package/scss/README.md +160 -0
- package/scss/_controls.scss +184 -30
- package/scss/_menus.scss +840 -672
- package/scss/_responsive.scss +67 -105
- package/scss/_volume.scss +67 -105
- package/src/README.md +559 -0
- package/src/controls.js +17 -5
- package/src/core.js +1237 -1060
- package/src/i18n.js +27 -1
- package/src/quality.js +478 -436
- package/src/subtitles.js +2 -2
package/plugins/README.md
CHANGED
|
@@ -1 +1,1017 @@
|
|
|
1
|
+
# MYETV Video Player - Plugin Development Guide
|
|
2
|
+
Complete guide for developing custom plugins for MYETV Video Player.
|
|
1
3
|
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Table of Contents
|
|
7
|
+
|
|
8
|
+
- [Introduction](#introduction)
|
|
9
|
+
- [Plugin Architecture](#plugin-architecture)
|
|
10
|
+
- [Creating a Plugin from Scratch](#creating-a-plugin-from-scratch)
|
|
11
|
+
- [API Reference](#api-reference)
|
|
12
|
+
- [HTML Integration](#html-integration)
|
|
13
|
+
- [Advanced Examples](#advanced-examples)
|
|
14
|
+
- [Best Practices](#best-practices)
|
|
15
|
+
- [Debugging](#debugging)
|
|
16
|
+
- [FAQ](#faq)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Introduction
|
|
21
|
+
|
|
22
|
+
MYETV Video Player features a powerful and extensible plugin system that allows developers to add custom functionality without modifying the core player code.
|
|
23
|
+
|
|
24
|
+
### Why Use Plugins?
|
|
25
|
+
|
|
26
|
+
- **Modularity**: Keep your code organized and maintainable
|
|
27
|
+
- **Reusability**: Share plugins across multiple projects
|
|
28
|
+
- **Non-invasive**: No need to modify core player files
|
|
29
|
+
- **Event-driven**: React to player events and lifecycle hooks
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Plugin Architecture
|
|
34
|
+
|
|
35
|
+
### How Plugins Work
|
|
36
|
+
|
|
37
|
+
1. **Registration**: Plugins are registered globally using `window.registerMYETVPlugin()`
|
|
38
|
+
2. **Initialization**: The player initializes plugins when created or via `usePlugin()`
|
|
39
|
+
3. **Lifecycle**: Plugins can hook into player events and lifecycle stages
|
|
40
|
+
4. **Disposal**: Plugins are properly cleaned up when removed or player is disposed
|
|
41
|
+
|
|
42
|
+
### Plugin Structure
|
|
43
|
+
|
|
44
|
+
A plugin can be:
|
|
45
|
+
- A **Constructor Function** (class)
|
|
46
|
+
- A **Factory Function**
|
|
47
|
+
- An **Object with a `create()` method**
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Creating a Plugin from Scratch
|
|
52
|
+
|
|
53
|
+
### Step 1: Basic Template
|
|
54
|
+
|
|
55
|
+
Create a new JavaScript file (e.g., `myetv-plugin-example.js`):
|
|
56
|
+
|
|
57
|
+
```javascript
|
|
58
|
+
/**
|
|
59
|
+
* MYETV Video Player - Example Plugin
|
|
60
|
+
* Description: This plugin demonstrates basic plugin functionality
|
|
61
|
+
* Author: Your Name
|
|
62
|
+
* Version: 1.0.0
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
(function(window) {
|
|
66
|
+
'use strict';
|
|
67
|
+
|
|
68
|
+
// Plugin Constructor
|
|
69
|
+
class ExamplePlugin {
|
|
70
|
+
constructor(player, options) {
|
|
71
|
+
// Store player instance and options
|
|
72
|
+
this.player = player;
|
|
73
|
+
this.options = Object.assign({
|
|
74
|
+
// Default options
|
|
75
|
+
enabled: true,
|
|
76
|
+
customMessage: 'Hello from Example Plugin!'
|
|
77
|
+
}, options);
|
|
78
|
+
|
|
79
|
+
// Plugin state
|
|
80
|
+
this.isActive = false;
|
|
81
|
+
|
|
82
|
+
// Log initialization
|
|
83
|
+
if (this.player.options.debug) {
|
|
84
|
+
console.log('🔌 Example Plugin initialized with options:', this.options);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Setup method - called automatically after plugin initialization
|
|
90
|
+
* Use this to bind events, create UI elements, etc.
|
|
91
|
+
*/
|
|
92
|
+
setup() {
|
|
93
|
+
if (!this.options.enabled) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Bind player events
|
|
98
|
+
this.bindEvents();
|
|
99
|
+
|
|
100
|
+
// Create custom UI elements
|
|
101
|
+
this.createUI();
|
|
102
|
+
|
|
103
|
+
// Mark plugin as active
|
|
104
|
+
this.isActive = true;
|
|
105
|
+
|
|
106
|
+
if (this.player.options.debug) {
|
|
107
|
+
console.log('🔌 Example Plugin setup completed');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bind player events
|
|
113
|
+
*/
|
|
114
|
+
bindEvents() {
|
|
115
|
+
// Listen to play event
|
|
116
|
+
this.player.addEventListener('played', (data) => {
|
|
117
|
+
console.log('Video started playing', data);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Listen to pause event
|
|
121
|
+
this.player.addEventListener('paused', (data) => {
|
|
122
|
+
console.log('Video paused', data);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Listen to timeupdate event
|
|
126
|
+
this.player.addEventListener('timeupdate', (data) => {
|
|
127
|
+
// This event fires frequently - use with caution
|
|
128
|
+
// console.log('Time update', data);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create custom UI elements
|
|
134
|
+
*/
|
|
135
|
+
createUI() {
|
|
136
|
+
// Add a custom button to the player controls
|
|
137
|
+
const button = this.player.addPluginControlButton({
|
|
138
|
+
id: 'example-plugin-btn',
|
|
139
|
+
icon: '🎯',
|
|
140
|
+
tooltip: 'Example Plugin Action',
|
|
141
|
+
position: 'right', // 'left' or 'right'
|
|
142
|
+
onClick: (e, player) => {
|
|
143
|
+
alert(this.options.customMessage);
|
|
144
|
+
console.log('Example plugin button clicked!');
|
|
145
|
+
},
|
|
146
|
+
className: 'example-plugin-button'
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (button && this.player.options.debug) {
|
|
150
|
+
console.log('🔌 Example Plugin: Custom button created');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Public method - can be called from outside
|
|
156
|
+
*/
|
|
157
|
+
doSomething() {
|
|
158
|
+
console.log('Example Plugin: doSomething() called');
|
|
159
|
+
alert(this.options.customMessage);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get plugin status
|
|
164
|
+
*/
|
|
165
|
+
getStatus() {
|
|
166
|
+
return {
|
|
167
|
+
isActive: this.isActive,
|
|
168
|
+
options: this.options
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Dispose method - cleanup when plugin is removed
|
|
174
|
+
*/
|
|
175
|
+
dispose() {
|
|
176
|
+
// Remove custom UI elements
|
|
177
|
+
this.player.removePluginControlButton('example-plugin-btn');
|
|
178
|
+
|
|
179
|
+
// Cleanup any timers, event listeners, etc.
|
|
180
|
+
this.isActive = false;
|
|
181
|
+
|
|
182
|
+
if (this.player.options.debug) {
|
|
183
|
+
console.log('🔌 Example Plugin disposed');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Register the plugin globally
|
|
189
|
+
if (typeof window.registerMYETVPlugin === 'function') {
|
|
190
|
+
window.registerMYETVPlugin('examplePlugin', ExamplePlugin);
|
|
191
|
+
} else {
|
|
192
|
+
console.error('🔌 MYETV Player plugin system not found. Make sure to load the player first.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
})(window);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## API Reference
|
|
201
|
+
|
|
202
|
+
### Player Instance Methods
|
|
203
|
+
|
|
204
|
+
The `player` instance passed to your plugin provides access to:
|
|
205
|
+
|
|
206
|
+
#### Playback Control
|
|
207
|
+
|
|
208
|
+
```javascript
|
|
209
|
+
player.play() // Play video
|
|
210
|
+
player.pause() // Pause video
|
|
211
|
+
player.togglePlayPause() // Toggle play/pause
|
|
212
|
+
player.getCurrentTime() // Get current time in seconds
|
|
213
|
+
player.setCurrentTime(time) // Set current time
|
|
214
|
+
player.getDuration() // Get video duration
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Volume Control
|
|
218
|
+
|
|
219
|
+
```javascript
|
|
220
|
+
player.getVolume() // Get volume (0-1)
|
|
221
|
+
player.setVolume(volume) // Set volume (0-1)
|
|
222
|
+
player.isMuted() // Check if muted
|
|
223
|
+
player.setMuted(muted) // Mute/unmute
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Quality Control
|
|
227
|
+
|
|
228
|
+
```javascript
|
|
229
|
+
player.getQualities() // Get available qualities
|
|
230
|
+
player.setQuality(quality) // Set quality
|
|
231
|
+
player.getSelectedQuality() // Get selected quality
|
|
232
|
+
player.getCurrentPlayingQuality() // Get actual playing quality
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### Event System
|
|
236
|
+
|
|
237
|
+
```javascript
|
|
238
|
+
player.addEventListener(eventType, callback)
|
|
239
|
+
player.removeEventListener(eventType, callback)
|
|
240
|
+
player.triggerEvent(eventType, data)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### UI Control
|
|
244
|
+
|
|
245
|
+
```javascript
|
|
246
|
+
player.addPluginControlButton(config)
|
|
247
|
+
player.removePluginControlButton(buttonId)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Available Events
|
|
251
|
+
|
|
252
|
+
| Event | Description | Data |
|
|
253
|
+
|-------|-------------|------|
|
|
254
|
+
| `played` | Video started playing | `{currentTime, duration}` |
|
|
255
|
+
| `paused` | Video paused | `{currentTime, duration}` |
|
|
256
|
+
| `timeupdate` | Time position updated | `{currentTime, duration, progress}` |
|
|
257
|
+
| `volumechange` | Volume changed | `{volume, muted}` |
|
|
258
|
+
| `qualitychange` | Quality changed | `{quality, previousQuality}` |
|
|
259
|
+
| `speedchange` | Playback speed changed | `{speed, previousSpeed}` |
|
|
260
|
+
| `ended` | Video ended | `{currentTime, duration}` |
|
|
261
|
+
| `fullscreenchange` | Fullscreen toggled | `{isFullscreen}` |
|
|
262
|
+
| `pipchange` | Picture-in-Picture toggled | `{isPiP}` |
|
|
263
|
+
| `subtitlechange` | Subtitle changed | `{track, enabled}` |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## HTML Integration
|
|
268
|
+
|
|
269
|
+
### Method 1: Load Plugin Before Player Initialization
|
|
270
|
+
|
|
271
|
+
```html
|
|
272
|
+
<!DOCTYPE html>
|
|
273
|
+
<html lang="en">
|
|
274
|
+
<head>
|
|
275
|
+
<meta charset="UTF-8">
|
|
276
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
277
|
+
<title>MYETV Player with Plugin</title>
|
|
278
|
+
|
|
279
|
+
<!-- Player CSS -->
|
|
280
|
+
<link rel="stylesheet" href="dist/myetv-player.css">
|
|
281
|
+
</head>
|
|
282
|
+
<body>
|
|
283
|
+
<!-- Video Element -->
|
|
284
|
+
<video id="myVideo" class="video-player">
|
|
285
|
+
<source src="video-720p.mp4" data-quality="720p" type="video/mp4">
|
|
286
|
+
<source src="video-480p.mp4" data-quality="480p" type="video/mp4">
|
|
287
|
+
</video>
|
|
288
|
+
|
|
289
|
+
<!-- Load Player Core -->
|
|
290
|
+
<script src="dist/myetv-player.js"></script>
|
|
291
|
+
|
|
292
|
+
<!-- Load Your Plugin -->
|
|
293
|
+
<script src="plugins/myetv-plugin-example.js"></script>
|
|
294
|
+
|
|
295
|
+
<!-- Initialize Player with Plugin -->
|
|
296
|
+
<script>
|
|
297
|
+
const player = new MYETVPlayer('myVideo', {
|
|
298
|
+
debug: true,
|
|
299
|
+
// Load plugin during initialization
|
|
300
|
+
plugins: {
|
|
301
|
+
examplePlugin: {
|
|
302
|
+
enabled: true,
|
|
303
|
+
customMessage: 'Hello from my custom plugin!'
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
</script>
|
|
308
|
+
</body>
|
|
309
|
+
</html>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### Method 2: Load Plugin After Player Initialization
|
|
315
|
+
|
|
316
|
+
```html
|
|
317
|
+
<!DOCTYPE html>
|
|
318
|
+
<html lang="en">
|
|
319
|
+
<head>
|
|
320
|
+
<meta charset="UTF-8">
|
|
321
|
+
<title>MYETV Player - Dynamic Plugin Loading</title>
|
|
322
|
+
<link rel="stylesheet" href="dist/myetv-player.css">
|
|
323
|
+
</head>
|
|
324
|
+
<body>
|
|
325
|
+
<video id="myVideo" class="video-player">
|
|
326
|
+
<source src="video.mp4" type="video/mp4">
|
|
327
|
+
</video>
|
|
328
|
+
|
|
329
|
+
<script src="dist/myetv-player.js"></script>
|
|
330
|
+
<script src="plugins/myetv-plugin-example.js"></script>
|
|
331
|
+
|
|
332
|
+
<script>
|
|
333
|
+
// Initialize player first
|
|
334
|
+
const player = new MYETVPlayer('myVideo', {
|
|
335
|
+
debug: true
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Load plugin dynamically later
|
|
339
|
+
player.usePlugin('examplePlugin', {
|
|
340
|
+
enabled: true,
|
|
341
|
+
customMessage: 'Dynamically loaded plugin!'
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Access plugin instance
|
|
345
|
+
const pluginInstance = player.getPlugin('examplePlugin');
|
|
346
|
+
|
|
347
|
+
// Call plugin methods
|
|
348
|
+
if (pluginInstance) {
|
|
349
|
+
pluginInstance.doSomething();
|
|
350
|
+
}
|
|
351
|
+
</script>
|
|
352
|
+
</body>
|
|
353
|
+
</html>
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### Method 3: Multiple Plugins
|
|
359
|
+
|
|
360
|
+
```html
|
|
361
|
+
<!DOCTYPE html>
|
|
362
|
+
<html lang="en">
|
|
363
|
+
<head>
|
|
364
|
+
<meta charset="UTF-8">
|
|
365
|
+
<title>MYETV Player - Multiple Plugins</title>
|
|
366
|
+
<link rel="stylesheet" href="dist/myetv-player.css">
|
|
367
|
+
</head>
|
|
368
|
+
<body>
|
|
369
|
+
<video id="myVideo" class="video-player">
|
|
370
|
+
<source src="video.mp4" type="video/mp4">
|
|
371
|
+
</video>
|
|
372
|
+
|
|
373
|
+
<script src="dist/myetv-player.js"></script>
|
|
374
|
+
|
|
375
|
+
<!-- Load multiple plugins -->
|
|
376
|
+
<script src="plugins/myetv-plugin-example.js"></script>
|
|
377
|
+
<script src="plugins/myetv-plugin-analytics.js"></script>
|
|
378
|
+
<script src="plugins/myetv-plugin-watermark.js"></script>
|
|
379
|
+
|
|
380
|
+
<script>
|
|
381
|
+
const player = new MYETVPlayer('myVideo', {
|
|
382
|
+
debug: true,
|
|
383
|
+
plugins: {
|
|
384
|
+
// Load multiple plugins at once
|
|
385
|
+
examplePlugin: {
|
|
386
|
+
enabled: true,
|
|
387
|
+
customMessage: 'Plugin 1 loaded!'
|
|
388
|
+
},
|
|
389
|
+
analyticsPlugin: {
|
|
390
|
+
trackingId: 'UA-XXXXX-Y',
|
|
391
|
+
sendEvents: true
|
|
392
|
+
},
|
|
393
|
+
watermarkPlugin: {
|
|
394
|
+
imageUrl: 'logo.png',
|
|
395
|
+
position: 'top-right'
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Check which plugins are loaded
|
|
401
|
+
console.log('Active plugins:', player.getActivePlugins());
|
|
402
|
+
</script>
|
|
403
|
+
</body>
|
|
404
|
+
</html>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
### Method 4: Conditional Plugin Loading
|
|
410
|
+
|
|
411
|
+
```html
|
|
412
|
+
<script>
|
|
413
|
+
const player = new MYETVPlayer('myVideo', {
|
|
414
|
+
debug: true
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Load plugin based on condition
|
|
418
|
+
if (window.innerWidth < 768) {
|
|
419
|
+
// Load mobile-specific plugin
|
|
420
|
+
player.usePlugin('mobilePlugin', {
|
|
421
|
+
enableTouchGestures: true
|
|
422
|
+
});
|
|
423
|
+
} else {
|
|
424
|
+
// Load desktop-specific plugin
|
|
425
|
+
player.usePlugin('desktopPlugin', {
|
|
426
|
+
enableKeyboardShortcuts: true
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Load plugin asynchronously
|
|
431
|
+
async function loadPluginAsync() {
|
|
432
|
+
try {
|
|
433
|
+
// Dynamically import plugin
|
|
434
|
+
await import('./plugins/myetv-plugin-async.js');
|
|
435
|
+
|
|
436
|
+
// Use plugin after loading
|
|
437
|
+
player.usePlugin('asyncPlugin', {
|
|
438
|
+
option1: 'value1'
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
console.log('Async plugin loaded successfully');
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.error('Failed to load async plugin:', error);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
loadPluginAsync();
|
|
448
|
+
</script>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## Advanced Examples
|
|
454
|
+
|
|
455
|
+
### Example 1: Analytics Plugin
|
|
456
|
+
|
|
457
|
+
```javascript
|
|
458
|
+
/**
|
|
459
|
+
* Analytics Plugin - Track video viewing analytics
|
|
460
|
+
*/
|
|
461
|
+
(function(window) {
|
|
462
|
+
'use strict';
|
|
463
|
+
|
|
464
|
+
class AnalyticsPlugin {
|
|
465
|
+
constructor(player, options) {
|
|
466
|
+
this.player = player;
|
|
467
|
+
this.options = Object.assign({
|
|
468
|
+
trackingId: '',
|
|
469
|
+
sendEvents: true,
|
|
470
|
+
trackMilestones: true
|
|
471
|
+
}, options);
|
|
472
|
+
|
|
473
|
+
this.analytics = {
|
|
474
|
+
sessionId: this.generateSessionId(),
|
|
475
|
+
startTime: null,
|
|
476
|
+
endTime: null,
|
|
477
|
+
totalWatchTime: 0,
|
|
478
|
+
pauseCount: 0,
|
|
479
|
+
seekCount: 0,
|
|
480
|
+
qualityChanges: 0,
|
|
481
|
+
milestones: []
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
setup() {
|
|
486
|
+
this.analytics.startTime = new Date();
|
|
487
|
+
this.bindAnalyticsEvents();
|
|
488
|
+
console.log('Analytics Plugin: Tracking started', this.analytics.sessionId);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
bindAnalyticsEvents() {
|
|
492
|
+
// Track play
|
|
493
|
+
this.player.addEventListener('played', () => {
|
|
494
|
+
this.sendEvent('video_play');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Track pause
|
|
498
|
+
this.player.addEventListener('paused', () => {
|
|
499
|
+
this.analytics.pauseCount++;
|
|
500
|
+
this.sendEvent('video_pause', { pauseCount: this.analytics.pauseCount });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Track completion
|
|
504
|
+
this.player.addEventListener('ended', () => {
|
|
505
|
+
this.analytics.endTime = new Date();
|
|
506
|
+
this.sendEvent('video_complete', this.getAnalyticsSummary());
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Track quality changes
|
|
510
|
+
this.player.addEventListener('qualitychange', (data) => {
|
|
511
|
+
this.analytics.qualityChanges++;
|
|
512
|
+
this.sendEvent('quality_change', data);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Track watch time
|
|
516
|
+
this.player.addEventListener('timeupdate', (data) => {
|
|
517
|
+
this.analytics.totalWatchTime = data.currentTime;
|
|
518
|
+
|
|
519
|
+
// Track milestones
|
|
520
|
+
if (this.options.trackMilestones) {
|
|
521
|
+
this.checkMilestones(data.progress);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
checkMilestones(progress) {
|
|
527
|
+
const milestones = [25, 50, 75, 90];
|
|
528
|
+
|
|
529
|
+
milestones.forEach(milestone => {
|
|
530
|
+
if (progress >= milestone && !this.analytics.milestones.includes(milestone)) {
|
|
531
|
+
this.analytics.milestones.push(milestone);
|
|
532
|
+
this.sendEvent('milestone_reached', { milestone: milestone });
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
sendEvent(eventName, data = {}) {
|
|
538
|
+
if (!this.options.sendEvents) return;
|
|
539
|
+
|
|
540
|
+
const eventData = {
|
|
541
|
+
sessionId: this.analytics.sessionId,
|
|
542
|
+
trackingId: this.options.trackingId,
|
|
543
|
+
event: eventName,
|
|
544
|
+
timestamp: new Date().toISOString(),
|
|
545
|
+
videoTitle: this.player.getVideoTitle(),
|
|
546
|
+
...data
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Send to your analytics backend
|
|
550
|
+
console.log('Analytics Event:', eventData);
|
|
551
|
+
|
|
552
|
+
// Example: Send to Google Analytics
|
|
553
|
+
if (window.gtag) {
|
|
554
|
+
window.gtag('event', eventName, eventData);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
getAnalyticsSummary() {
|
|
559
|
+
return {
|
|
560
|
+
...this.analytics,
|
|
561
|
+
duration: this.analytics.endTime - this.analytics.startTime
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
generateSessionId() {
|
|
566
|
+
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
dispose() {
|
|
570
|
+
console.log('Analytics Plugin: Final summary', this.getAnalyticsSummary());
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
window.registerMYETVPlugin('analyticsPlugin', AnalyticsPlugin);
|
|
575
|
+
|
|
576
|
+
})(window);
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
### Example 2: Watermark Plugin
|
|
582
|
+
|
|
583
|
+
```javascript
|
|
584
|
+
/**
|
|
585
|
+
* Custom Watermark Plugin
|
|
586
|
+
*/
|
|
587
|
+
(function(window) {
|
|
588
|
+
'use strict';
|
|
589
|
+
|
|
590
|
+
class WatermarkPlugin {
|
|
591
|
+
constructor(player, options) {
|
|
592
|
+
this.player = player;
|
|
593
|
+
this.options = Object.assign({
|
|
594
|
+
imageUrl: '',
|
|
595
|
+
position: 'top-right', // top-left, top-right, bottom-left, bottom-right
|
|
596
|
+
opacity: 0.7,
|
|
597
|
+
size: '80px',
|
|
598
|
+
link: '',
|
|
599
|
+
fadeOnControls: true
|
|
600
|
+
}, options);
|
|
601
|
+
|
|
602
|
+
this.watermarkElement = null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
setup() {
|
|
606
|
+
if (!this.options.imageUrl) {
|
|
607
|
+
console.warn('🔌 Watermark Plugin: No image URL provided');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.createWatermark();
|
|
612
|
+
|
|
613
|
+
if (this.options.fadeOnControls) {
|
|
614
|
+
this.bindControlsEvents();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
createWatermark() {
|
|
619
|
+
const watermark = document.createElement('div');
|
|
620
|
+
watermark.className = 'custom-watermark';
|
|
621
|
+
watermark.style.cssText = `
|
|
622
|
+
position: absolute;
|
|
623
|
+
z-index: 10;
|
|
624
|
+
opacity: ${this.options.opacity};
|
|
625
|
+
transition: opacity 0.3s;
|
|
626
|
+
pointer-events: ${this.options.link ? 'auto' : 'none'};
|
|
627
|
+
`;
|
|
628
|
+
|
|
629
|
+
// Position
|
|
630
|
+
const positions = {
|
|
631
|
+
'top-left': 'top: 10px; left: 10px;',
|
|
632
|
+
'top-right': 'top: 10px; right: 10px;',
|
|
633
|
+
'bottom-left': 'bottom: 60px; left: 10px;',
|
|
634
|
+
'bottom-right': 'bottom: 60px; right: 10px;'
|
|
635
|
+
};
|
|
636
|
+
watermark.style.cssText += positions[this.options.position] || positions['top-right'];
|
|
637
|
+
|
|
638
|
+
// Create image
|
|
639
|
+
const img = document.createElement('img');
|
|
640
|
+
img.src = this.options.imageUrl;
|
|
641
|
+
img.style.cssText = `
|
|
642
|
+
width: ${this.options.size};
|
|
643
|
+
height: auto;
|
|
644
|
+
display: block;
|
|
645
|
+
`;
|
|
646
|
+
|
|
647
|
+
// Add link if provided
|
|
648
|
+
if (this.options.link) {
|
|
649
|
+
const link = document.createElement('a');
|
|
650
|
+
link.href = this.options.link;
|
|
651
|
+
link.target = '_blank';
|
|
652
|
+
link.rel = 'noopener noreferrer';
|
|
653
|
+
link.appendChild(img);
|
|
654
|
+
watermark.appendChild(link);
|
|
655
|
+
} else {
|
|
656
|
+
watermark.appendChild(img);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Add to player container
|
|
660
|
+
this.player.container.appendChild(watermark);
|
|
661
|
+
this.watermarkElement = watermark;
|
|
662
|
+
|
|
663
|
+
console.log('🔌 Watermark Plugin: Watermark created');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
bindControlsEvents() {
|
|
667
|
+
// Fade watermark when controls are shown
|
|
668
|
+
this.player.addEventListener('controlsshown', () => {
|
|
669
|
+
if (this.watermarkElement) {
|
|
670
|
+
this.watermarkElement.style.opacity = '0.3';
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
this.player.addEventListener('controlshidden', () => {
|
|
675
|
+
if (this.watermarkElement) {
|
|
676
|
+
this.watermarkElement.style.opacity = this.options.opacity;
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
dispose() {
|
|
682
|
+
if (this.watermarkElement) {
|
|
683
|
+
this.watermarkElement.remove();
|
|
684
|
+
this.watermarkElement = null;
|
|
685
|
+
}
|
|
686
|
+
console.log('🔌 Watermark Plugin disposed');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
window.registerMYETVPlugin('watermarkPlugin', WatermarkPlugin);
|
|
691
|
+
|
|
692
|
+
})(window);
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
### Example 3: Keyboard Shortcuts Plugin
|
|
698
|
+
|
|
699
|
+
```javascript
|
|
700
|
+
/**
|
|
701
|
+
* Enhanced Keyboard Shortcuts Plugin
|
|
702
|
+
*/
|
|
703
|
+
(function(window) {
|
|
704
|
+
'use strict';
|
|
705
|
+
|
|
706
|
+
class KeyboardShortcutsPlugin {
|
|
707
|
+
constructor(player, options) {
|
|
708
|
+
this.player = player;
|
|
709
|
+
this.options = Object.assign({
|
|
710
|
+
enabled: true,
|
|
711
|
+
shortcuts: {
|
|
712
|
+
'Space': 'togglePlayPause',
|
|
713
|
+
'ArrowLeft': 'seekBackward',
|
|
714
|
+
'ArrowRight': 'seekForward',
|
|
715
|
+
'ArrowUp': 'volumeUp',
|
|
716
|
+
'ArrowDown': 'volumeDown',
|
|
717
|
+
'KeyM': 'toggleMute',
|
|
718
|
+
'KeyF': 'toggleFullscreen',
|
|
719
|
+
'KeyP': 'togglePiP',
|
|
720
|
+
'KeyS': 'toggleSubtitles',
|
|
721
|
+
'Digit0': 'seekToStart',
|
|
722
|
+
'Digit1': 'seekToPercent10',
|
|
723
|
+
'Digit2': 'seekToPercent20',
|
|
724
|
+
'Digit3': 'seekToPercent30',
|
|
725
|
+
'Digit4': 'seekToPercent40',
|
|
726
|
+
'Digit5': 'seekToPercent50',
|
|
727
|
+
'Digit6': 'seekToPercent60',
|
|
728
|
+
'Digit7': 'seekToPercent70',
|
|
729
|
+
'Digit8': 'seekToPercent80',
|
|
730
|
+
'Digit9': 'seekToPercent90'
|
|
731
|
+
},
|
|
732
|
+
seekStep: 10, // seconds
|
|
733
|
+
volumeStep: 0.1 // 0-1
|
|
734
|
+
}, options);
|
|
735
|
+
|
|
736
|
+
this.boundKeyHandler = null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
setup() {
|
|
740
|
+
if (!this.options.enabled) return;
|
|
741
|
+
|
|
742
|
+
this.boundKeyHandler = this.handleKeyPress.bind(this);
|
|
743
|
+
document.addEventListener('keydown', this.boundKeyHandler);
|
|
744
|
+
|
|
745
|
+
console.log('Keyboard Shortcuts Plugin: Enabled');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
handleKeyPress(e) {
|
|
749
|
+
// Ignore if typing in input
|
|
750
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const action = this.options.shortcuts[e.code];
|
|
755
|
+
if (!action) return;
|
|
756
|
+
|
|
757
|
+
e.preventDefault();
|
|
758
|
+
|
|
759
|
+
// Execute action
|
|
760
|
+
this.executeAction(action);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
executeAction(action) {
|
|
764
|
+
const actions = {
|
|
765
|
+
togglePlayPause: () => this.player.togglePlayPause(),
|
|
766
|
+
seekBackward: () => this.player.setCurrentTime(this.player.getCurrentTime() - this.options.seekStep),
|
|
767
|
+
seekForward: () => this.player.setCurrentTime(this.player.getCurrentTime() + this.options.seekStep),
|
|
768
|
+
volumeUp: () => this.player.setVolume(Math.min(1, this.player.getVolume() + this.options.volumeStep)),
|
|
769
|
+
volumeDown: () => this.player.setVolume(Math.max(0, this.player.getVolume() - this.options.volumeStep)),
|
|
770
|
+
toggleMute: () => this.player.setMuted(!this.player.isMuted()),
|
|
771
|
+
toggleFullscreen: () => this.player.toggleFullscreen(),
|
|
772
|
+
togglePiP: () => this.player.togglePictureInPicture(),
|
|
773
|
+
toggleSubtitles: () => this.player.toggleSubtitles(),
|
|
774
|
+
seekToStart: () => this.player.setCurrentTime(0),
|
|
775
|
+
seekToPercent10: () => this.seekToPercent(10),
|
|
776
|
+
seekToPercent20: () => this.seekToPercent(20),
|
|
777
|
+
seekToPercent30: () => this.seekToPercent(30),
|
|
778
|
+
seekToPercent40: () => this.seekToPercent(40),
|
|
779
|
+
seekToPercent50: () => this.seekToPercent(50),
|
|
780
|
+
seekToPercent60: () => this.seekToPercent(60),
|
|
781
|
+
seekToPercent70: () => this.seekToPercent(70),
|
|
782
|
+
seekToPercent80: () => this.seekToPercent(80),
|
|
783
|
+
seekToPercent90: () => this.seekToPercent(90)
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
if (actions[action]) {
|
|
787
|
+
actions[action]();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
seekToPercent(percent) {
|
|
792
|
+
const duration = this.player.getDuration();
|
|
793
|
+
if (duration > 0) {
|
|
794
|
+
this.player.setCurrentTime((duration * percent) / 100);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
dispose() {
|
|
799
|
+
if (this.boundKeyHandler) {
|
|
800
|
+
document.removeEventListener('keydown', this.boundKeyHandler);
|
|
801
|
+
this.boundKeyHandler = null;
|
|
802
|
+
}
|
|
803
|
+
console.log('Keyboard Shortcuts Plugin disposed');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
window.registerMYETVPlugin('keyboardShortcutsPlugin', KeyboardShortcutsPlugin);
|
|
808
|
+
|
|
809
|
+
})(window);
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## Best Practices
|
|
815
|
+
|
|
816
|
+
### 1. Always Check Debug Mode
|
|
817
|
+
|
|
818
|
+
```javascript
|
|
819
|
+
if (this.player.options.debug) {
|
|
820
|
+
console.log('Plugin debug message');
|
|
821
|
+
}
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### 2. Handle Errors Gracefully
|
|
825
|
+
|
|
826
|
+
```javascript
|
|
827
|
+
setup() {
|
|
828
|
+
try {
|
|
829
|
+
this.createUI();
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.error('Plugin setup failed:', error);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### 3. Clean Up Resources
|
|
837
|
+
|
|
838
|
+
```javascript
|
|
839
|
+
dispose() {
|
|
840
|
+
// Remove event listeners
|
|
841
|
+
// Clear timers
|
|
842
|
+
// Remove DOM elements
|
|
843
|
+
// Reset state
|
|
844
|
+
}
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
### 4. Provide Default Options
|
|
848
|
+
|
|
849
|
+
```javascript
|
|
850
|
+
this.options = Object.assign({
|
|
851
|
+
enabled: true,
|
|
852
|
+
option1: 'default1',
|
|
853
|
+
option2: 'default2'
|
|
854
|
+
}, options);
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
### 5. Use Namespaced CSS Classes
|
|
858
|
+
|
|
859
|
+
```javascript
|
|
860
|
+
button.className = 'myplugin-button'; // Not just 'button'
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
### 6. Document Your Plugin
|
|
864
|
+
|
|
865
|
+
```javascript
|
|
866
|
+
/**
|
|
867
|
+
* MyPlugin - Description
|
|
868
|
+
* @param {Object} player - Player instance
|
|
869
|
+
* @param {Object} options - Plugin options
|
|
870
|
+
* @param {Boolean} options.enabled - Enable/disable plugin
|
|
871
|
+
* @param {String} options.customOption - Custom option description
|
|
872
|
+
*/
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
## Debugging
|
|
878
|
+
|
|
879
|
+
### Enable Debug Mode
|
|
880
|
+
|
|
881
|
+
```javascript
|
|
882
|
+
const player = new MYETVPlayer('myVideo', {
|
|
883
|
+
debug: true // Enable debug logging
|
|
884
|
+
});
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
### Check Plugin Status
|
|
888
|
+
|
|
889
|
+
```javascript
|
|
890
|
+
// Check if plugin is loaded
|
|
891
|
+
if (player.hasPlugin('myPlugin')) {
|
|
892
|
+
console.log('Plugin is loaded');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Get plugin instance
|
|
896
|
+
const plugin = player.getPlugin('myPlugin');
|
|
897
|
+
console.log('Plugin instance:', plugin);
|
|
898
|
+
|
|
899
|
+
// Get all active plugins
|
|
900
|
+
console.log('Active plugins:', player.getActivePlugins());
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
### Console Debugging
|
|
904
|
+
|
|
905
|
+
```javascript
|
|
906
|
+
// In your plugin
|
|
907
|
+
setup() {
|
|
908
|
+
console.log('[MyPlugin] Setup started');
|
|
909
|
+
console.log('[MyPlugin] Options:', this.options);
|
|
910
|
+
console.log('[MyPlugin] Player state:', this.player.getPlayerState());
|
|
911
|
+
}
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Testing Plugin Events
|
|
915
|
+
|
|
916
|
+
```javascript
|
|
917
|
+
const player = new MYETVPlayer('myVideo', { debug: true });
|
|
918
|
+
|
|
919
|
+
// Listen to plugin setup events
|
|
920
|
+
player.addEventListener('pluginsetup', (data) => {
|
|
921
|
+
console.log('Plugin setup:', data);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
player.addEventListener('pluginsetup:myPlugin', (data) => {
|
|
925
|
+
console.log('My specific plugin setup:', data);
|
|
926
|
+
});
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
## FAQ
|
|
932
|
+
|
|
933
|
+
### Q: Can I create a plugin without modifying core files?
|
|
934
|
+
|
|
935
|
+
**A:** Yes! That's the whole point. Plugins are completely separate from the core player.
|
|
936
|
+
|
|
937
|
+
### Q: How do I pass data between plugins?
|
|
938
|
+
|
|
939
|
+
**A:** Use the player's event system or store data in `player.data` object.
|
|
940
|
+
|
|
941
|
+
```javascript
|
|
942
|
+
// Plugin A
|
|
943
|
+
this.player.data = this.player.data || {};
|
|
944
|
+
this.player.data.sharedValue = 'some data';
|
|
945
|
+
|
|
946
|
+
// Plugin B
|
|
947
|
+
const sharedValue = this.player.data?.sharedValue;
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
### Q: Can I override core player methods?
|
|
951
|
+
|
|
952
|
+
**A:** Not recommended, but technically possible. Use hooks and events instead.
|
|
953
|
+
|
|
954
|
+
### Q: How do I distribute my plugin?
|
|
955
|
+
|
|
956
|
+
**A:** Publish it as a standalone JavaScript file. Users can include it via `<script>` tag or module import.
|
|
957
|
+
|
|
958
|
+
### Q: Can plugins work with multiple player instances?
|
|
959
|
+
|
|
960
|
+
**A:** Yes! Each player instance gets its own plugin instance.
|
|
961
|
+
|
|
962
|
+
```javascript
|
|
963
|
+
const player1 = new MYETVPlayer('video1');
|
|
964
|
+
const player2 = new MYETVPlayer('video2');
|
|
965
|
+
|
|
966
|
+
player1.usePlugin('myPlugin', { option: 'value1' });
|
|
967
|
+
player2.usePlugin('myPlugin', { option: 'value2' });
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
### Q: How do I make my plugin configurable?
|
|
971
|
+
|
|
972
|
+
**A:** Accept options in the constructor and provide defaults.
|
|
973
|
+
|
|
974
|
+
```javascript
|
|
975
|
+
constructor(player, options) {
|
|
976
|
+
this.options = Object.assign({
|
|
977
|
+
// Defaults
|
|
978
|
+
setting1: 'default',
|
|
979
|
+
setting2: true
|
|
980
|
+
}, options);
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
### Q: Can I use async/await in plugins?
|
|
985
|
+
|
|
986
|
+
**A:** Yes! Plugins fully support modern JavaScript features.
|
|
987
|
+
|
|
988
|
+
```javascript
|
|
989
|
+
async setup() {
|
|
990
|
+
const data = await fetch('/api/plugin-data');
|
|
991
|
+
this.data = await data.json();
|
|
992
|
+
}
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
---
|
|
996
|
+
|
|
997
|
+
## 📝 License
|
|
998
|
+
|
|
999
|
+
MIT License - See main project for details.
|
|
1000
|
+
|
|
1001
|
+
---
|
|
1002
|
+
|
|
1003
|
+
## Contributing
|
|
1004
|
+
|
|
1005
|
+
Contributions are welcome! Please submit pull requests or open issues on GitHub.
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
## Support
|
|
1010
|
+
|
|
1011
|
+
- **GitHub**: [MYETV Video Player Open Source]([https://github.com/yourusername/myetv-player](https://github.com/OskarCosimo/myetv-video-player-opensource/))
|
|
1012
|
+
- **Website**: [https://www.myetv.tv](https://www.myetv.tv)
|
|
1013
|
+
- **Author**: [https://oskarcosimo.com](https://oskarcosimo.com)
|
|
1014
|
+
|
|
1015
|
+
---
|
|
1016
|
+
|
|
1017
|
+
**Happy Plugin Development!**
|