myetv-player 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +30 -0
- package/LICENSE +21 -0
- package/README.md +866 -0
- package/build.js +189 -0
- package/css/README.md +1 -0
- package/css/myetv-player.css +13702 -0
- package/css/myetv-player.min.css +1 -0
- package/dist/README.md +1 -0
- package/dist/myetv-player.js +6408 -0
- package/dist/myetv-player.min.js +6183 -0
- package/package.json +27 -0
- package/plugins/README.md +1 -0
- package/plugins/google-analytics/README.md +1 -0
- package/plugins/google-analytics/myetv-player-g-analytics-plugin.js +548 -0
- package/plugins/youtube/README.md +1 -0
- package/plugins/youtube/myetv-player-youtube-plugin.js +418 -0
- package/scss/README.md +1 -0
- package/scss/_audio-player.scss +21 -0
- package/scss/_base.scss +131 -0
- package/scss/_controls.scss +30 -0
- package/scss/_loading.scss +111 -0
- package/scss/_menus.scss +4070 -0
- package/scss/_mixins.scss +112 -0
- package/scss/_poster.scss +8 -0
- package/scss/_progress-bar.scss +2203 -0
- package/scss/_resolution.scss +68 -0
- package/scss/_responsive.scss +1532 -0
- package/scss/_themes.scss +30 -0
- package/scss/_title-overlay.scss +2262 -0
- package/scss/_tooltips.scss +7 -0
- package/scss/_variables.scss +49 -0
- package/scss/_video.scss +2401 -0
- package/scss/_volume.scss +1981 -0
- package/scss/_watermark.scss +8 -0
- package/scss/myetv-player.scss +51 -0
- package/scss/package.json +16 -0
- package/src/README.md +1 -0
- package/src/chapters.js +521 -0
- package/src/controls.js +1005 -0
- package/src/core.js +1650 -0
- package/src/events.js +330 -0
- package/src/fullscreen.js +82 -0
- package/src/i18n.js +348 -0
- package/src/playlist.js +177 -0
- package/src/plugins.js +384 -0
- package/src/quality.js +921 -0
- package/src/streaming.js +346 -0
- package/src/subtitles.js +426 -0
- package/src/utils.js +51 -0
- package/src/watermark.js +195 -0
package/README.md
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
# MYETV Audio/Video Player Open Source
|
|
2
|
+
|
|
3
|
+
A modern and complete HTML5 + JavaScript + css video player with custom controls, multiple quality support, subtitles, Picture-in-Picture and much more.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Custom controls** with intelligent auto-hide
|
|
8
|
+
- Built to work with both .mp4 or .webm or .mp3 (**download streaming**) but also with hls or dash (**adaptive streaming**)
|
|
9
|
+
- **Multiple video qualities** with automatic selection based on connection
|
|
10
|
+
- **Subtitles** with multiple track support
|
|
11
|
+
- **Chapters** with images and customized colors
|
|
12
|
+
- Custom **Plugins** to enhance player's functionality
|
|
13
|
+
- **Picture-in-Picture** mode (where supported)
|
|
14
|
+
- **Complete keyboard controls**
|
|
15
|
+
- **Internationalization** (i18n) multilingual
|
|
16
|
+
- **Responsive design** for mobile and desktop devices
|
|
17
|
+
- **Customizable** title overlay
|
|
18
|
+
- **Customizable** brand logo in the controlbar
|
|
19
|
+
- **Debug mode** for developers
|
|
20
|
+
- **Extensive APIs** for programmatic control
|
|
21
|
+
|
|
22
|
+
## Demo page
|
|
23
|
+
[MYETV Video Player Demo Page: https://oskarcosimo.com/myetv-video-player/myetv-player-demo.html](https://oskarcosimo.com/myetv-video-player/myetv-player-demo.html)
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### Include Required Files
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
<!-- Player CSS -->
|
|
31
|
+
<link rel="stylesheet" href="css/myetv-player.min.css">
|
|
32
|
+
<!-- Player JavaScript -->
|
|
33
|
+
<script src="dist/myetv-player.min.js"></script>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Basic Usage
|
|
37
|
+
|
|
38
|
+
### HTML
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
<video id="my-video" width="800" height="450">
|
|
42
|
+
<source src="video-480p.mp4" type="video/mp4" data-quality="480p">
|
|
43
|
+
<source src="video-720p.mp4" type="video/mp4" data-quality="720p">
|
|
44
|
+
<source src="video-1080p.mp4" type="video/mp4" data-quality="1080p">
|
|
45
|
+
<!-- Optional subtitles -->
|
|
46
|
+
<track kind="subtitles" src="subtitles-en.vtt" srclang="en" label="English">
|
|
47
|
+
<track kind="subtitles" src="subtitles-it.vtt" srclang="it" label="Italiano">
|
|
48
|
+
</video>
|
|
49
|
+
|
|
50
|
+
// Basic initialization
|
|
51
|
+
const player = new MYETVvideoplayer('my-video');
|
|
52
|
+
|
|
53
|
+
// Initialization with options
|
|
54
|
+
const player = new MYETVvideoplayer('my-video', {
|
|
55
|
+
autoplay: true,
|
|
56
|
+
defaultQuality: '720p',
|
|
57
|
+
showTitleOverlay: true,
|
|
58
|
+
videoTitle: 'My Video',
|
|
59
|
+
language: 'en',
|
|
60
|
+
debug: false
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Initialization Options
|
|
65
|
+
|
|
66
|
+
| Option | Type | Default | Description |
|
|
67
|
+
|--------|------|---------|-------------|
|
|
68
|
+
| `poster` | string | `''` | URL of a cover image (optional) |
|
|
69
|
+
| `showPosterOnEnd` | boolean | `false` | Show the cover image when the video ends |
|
|
70
|
+
| `showQualitySelector` | boolean | `true` | Show video quality selector |
|
|
71
|
+
| `showSpeedControl` | boolean | `true` | Show playback speed controls |
|
|
72
|
+
| `showFullscreen` | boolean | `true` | Show fullscreen button |
|
|
73
|
+
| `showPictureInPicture` | boolean | `true` | Show Picture-in-Picture button |
|
|
74
|
+
| `showSubtitles` | boolean | `true` | Show subtitles controls (the button) - it is automatically true only if subtitles track are detected |
|
|
75
|
+
| `subtitlesEnabled` | boolean | `false` | Enable/Disable subtitles at player ready |
|
|
76
|
+
| `chapters` | string | json | Enable/Disable chapters: chapter can be in json format or string format (see below) |
|
|
77
|
+
| `plugins` | string | json | Add a customized plugin to the player to extend its functionality (see below) |
|
|
78
|
+
| `showSeekTooltip` | boolean | `true` | Show tooltip during seek |
|
|
79
|
+
| `volumeSlider` | string | `horizontal` | Volume slider 'horizontal' or 'vertical': the horizontal slider is always visible and have the automatic fallback to vertical under 550px of width; the vertical slider is only vertical at any width and automatically disapper if mouse is not hover the volume button |
|
|
80
|
+
| `autoplay` | boolean | `false` | Start video automatically |
|
|
81
|
+
| `loop` | boolean | `false` | Optional if the video should loop
|
|
82
|
+
| `resolution` | string | `normal` | resolution type: "normal" same resolution of the native video; "4:3"; "16:9"; "stretched" the video will be stretched in all the container; "fit-to-screen" the video will fit the screen but can be cutted; "scale-to-fit" fit the screen but preserve aspect ration and not cut |
|
|
83
|
+
| `autoHide` | boolean | `true` | Auto-hide controls |
|
|
84
|
+
| `autoHideDelay` | number | `3000` | Auto-hide delay in milliseconds |
|
|
85
|
+
| `pauseClick` | boolean | `true` | Enable or disable the click on the video to pause/resume |
|
|
86
|
+
| `doubleTapPause` | boolean | `true` | First touch shows controls, second touch pauses (usefull on touch devices) |
|
|
87
|
+
| `keyboardControls` | boolean | `true` | Enable keyboard controls |
|
|
88
|
+
| `defaultQuality` | string | `'auto'` | Default video quality |
|
|
89
|
+
| `showTitleOverlay` | boolean | `false` | Show video title overlay |
|
|
90
|
+
| `videoTitle` | string | `''` | Title to show in overlay |
|
|
91
|
+
| `persistentTitle` | boolean | `false` | Keep title always visible |
|
|
92
|
+
| `language` | string | `en` | Interface language code |
|
|
93
|
+
| `brandLogoEnabled` | boolean | `false` | Show/hide the brand logo in the controlbar |
|
|
94
|
+
| `brandLogoUrl` | string | `''` | Brand logo url in the controlbar (png, jpg, gif) - image height 44px - image width 120px |
|
|
95
|
+
| `brandLogoLinkUrl` | string | `''` | Optional URL to open in a new page when clicking the brand logo in the controlbar
|
|
96
|
+
| `watermarkUrl` | string | `''` | Optional URL of the image watermark over the video, reccomended dimension: width: 180px, height: 100px
|
|
97
|
+
| `watermarkLink` | string | `''` | Optional URL to open in a new page when clicking the watermark logo in the video
|
|
98
|
+
| `watermarkPosition` | string | `''` | Optional where to show the watermark logo in the video (values are: top-left, top-right, bottom-left, bottom-right)
|
|
99
|
+
| `watermarkTitle` | string | `''` | Optional title to show when the mouse is over the watermark logo in the video
|
|
100
|
+
| `hideWatermark` | boolean | `true` | Optional hide watermark logo with the controlbar or show the watermark logo always visible
|
|
101
|
+
| `playlistEnabled` | boolean | `false` | Optional if the playlist of video is enabled (html structured)
|
|
102
|
+
| `playlistAutoPlay` | boolean | `false` | Optional if the playlist should autoplay
|
|
103
|
+
| `playlistLoop` | boolean | `false` | Optional if the playlist should loop
|
|
104
|
+
| `adaptiveStreaming` | boolean | `false` | Enable HLS/DASH adaptive streaming
|
|
105
|
+
| `adaptiveQualityControl` | boolean | `false` | Enable the menu quality with adaptive streaming
|
|
106
|
+
| `audiofile` | boolean | `false` | Optional if the file is only audio (no video) |
|
|
107
|
+
| `audiowave` | boolean | `false` | Optional if the file is only audio, show the audio wave as video (with the browser Web Audio API) |
|
|
108
|
+
| `debug` | boolean | `false` | Enable debug logs |
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
## API Methods
|
|
112
|
+
### Basic Controls
|
|
113
|
+
```
|
|
114
|
+
// Playback
|
|
115
|
+
player.play(); // Start playback
|
|
116
|
+
player.pause(); // Pause playback
|
|
117
|
+
player.togglePlayPause(); // Toggle play/pause
|
|
118
|
+
|
|
119
|
+
// Volume
|
|
120
|
+
player.setVolume(0.8); // Set volume (0-1)
|
|
121
|
+
player.getVolume(); // Get current volume
|
|
122
|
+
player.toggleMute(); // Toggle mute
|
|
123
|
+
player.setMuted(true); // Set mute
|
|
124
|
+
```
|
|
125
|
+
### Time Controls
|
|
126
|
+
```
|
|
127
|
+
// Position
|
|
128
|
+
player.setCurrentTime(120); // Go to second 120
|
|
129
|
+
player.getCurrentTime(); // Current position
|
|
130
|
+
player.getDuration(); // Total duration
|
|
131
|
+
player.skipTime(10); // Skip 10 seconds forward
|
|
132
|
+
player.skipTime(-10); // Skip 10 seconds backward
|
|
133
|
+
```
|
|
134
|
+
### Poster Image (cover image)
|
|
135
|
+
```
|
|
136
|
+
// Set poster after initialization
|
|
137
|
+
player.setPoster('https://example.com/poster.jpg');
|
|
138
|
+
|
|
139
|
+
// Get current poster
|
|
140
|
+
const currentPoster = player.getPoster();
|
|
141
|
+
|
|
142
|
+
// Remove poster
|
|
143
|
+
player.removePoster();
|
|
144
|
+
|
|
145
|
+
// Toggle poster visibility
|
|
146
|
+
player.togglePoster(true); // Show
|
|
147
|
+
player.togglePoster(false); // Hide
|
|
148
|
+
|
|
149
|
+
// Check if poster is visible
|
|
150
|
+
if (player.isPosterVisible()) {
|
|
151
|
+
console.log('Poster is visible');
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
### Quality Controls
|
|
155
|
+
```
|
|
156
|
+
// Video quality
|
|
157
|
+
player.setDefaultQuality('720p'); // Set default quality
|
|
158
|
+
player.setQuality('1080p'); // Change quality
|
|
159
|
+
player.getSelectedQuality(); // Selected quality
|
|
160
|
+
player.getCurrentPlayingQuality(); // Actual playing quality
|
|
161
|
+
player.enableAutoQuality(); // Enable automatic selection
|
|
162
|
+
```
|
|
163
|
+
### Subtitle Controls
|
|
164
|
+
```
|
|
165
|
+
// Subtitles
|
|
166
|
+
player.toggleSubtitles(); // Toggle subtitles
|
|
167
|
+
player.enableSubtitleTrack(0); // Enable subtitle track
|
|
168
|
+
player.disableSubtitles(); // Disable subtitles
|
|
169
|
+
player.getAvailableSubtitles(); // List available subtitles
|
|
170
|
+
```
|
|
171
|
+
### Chapters Controls
|
|
172
|
+
```
|
|
173
|
+
// Get current chapter
|
|
174
|
+
const current = player.getCurrentChapter();
|
|
175
|
+
|
|
176
|
+
// Navigate chapters
|
|
177
|
+
player.nextChapter();
|
|
178
|
+
player.previousChapter();
|
|
179
|
+
player.jumpToChapter(2);
|
|
180
|
+
|
|
181
|
+
// Get all chapters
|
|
182
|
+
const allChapters = player.getChapters();
|
|
183
|
+
|
|
184
|
+
// Update chapters dynamically
|
|
185
|
+
player.setChapters([...]);
|
|
186
|
+
|
|
187
|
+
// Clear chapters
|
|
188
|
+
player.clearChapters();
|
|
189
|
+
```
|
|
190
|
+
### Plugins Controls
|
|
191
|
+
```
|
|
192
|
+
// Add plugins dynamically
|
|
193
|
+
player.usePlugin('youtube', {
|
|
194
|
+
apiKey: 'your-api-key'
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Upload YouTube videos (based on the YouTube plugin example)
|
|
198
|
+
player.loadYouTubeVideo('dQw4w9WgXcQ');
|
|
199
|
+
|
|
200
|
+
// Check if a plugin is active
|
|
201
|
+
if (player.hasPlugin('youtube')) {
|
|
202
|
+
console.log('YouTube plugin is active');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Get plugin instance
|
|
206
|
+
const youtubePlugin = player.getPlugin('youtube');
|
|
207
|
+
|
|
208
|
+
// Remove plugins from the player
|
|
209
|
+
player.removePlugin('youtube');
|
|
210
|
+
```
|
|
211
|
+
### Screen Controls
|
|
212
|
+
```
|
|
213
|
+
// Fullscreen and Picture-in-Picture
|
|
214
|
+
player.toggleFullscreen(); // Toggle fullscreen
|
|
215
|
+
player.enterFullscreen(); // Enter fullscreen
|
|
216
|
+
player.exitFullscreen(); // Exit fullscreen
|
|
217
|
+
player.togglePictureInPicture(); // Toggle Picture-in-Picture
|
|
218
|
+
```
|
|
219
|
+
### Brand Logo Controls
|
|
220
|
+
```
|
|
221
|
+
// Fullscreen and Picture-in-Picture
|
|
222
|
+
player.setBrandLogo(enabled, url, linkUrl) //change brand logo dynamically
|
|
223
|
+
player.getBrandLogoSettings() //get current brand logo settings
|
|
224
|
+
```
|
|
225
|
+
### Watermark Logo Controls
|
|
226
|
+
```
|
|
227
|
+
// Change watermark dynamically
|
|
228
|
+
player.setWatermark(
|
|
229
|
+
'https://example.com/new-logo.png',
|
|
230
|
+
'https://example.com/promo',
|
|
231
|
+
'topleft',
|
|
232
|
+
'Special promotion'
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Change only position
|
|
236
|
+
player.setWatermarkPosition('topright');
|
|
237
|
+
|
|
238
|
+
// Get current settings
|
|
239
|
+
const settings = player.getWatermarkSettings();
|
|
240
|
+
console.log(settings);
|
|
241
|
+
|
|
242
|
+
// Remove watermark
|
|
243
|
+
player.removeWatermark();
|
|
244
|
+
|
|
245
|
+
//hide with the controlbar or always show the watermark logo
|
|
246
|
+
player.setWatermarkAutoHide(false);
|
|
247
|
+
```
|
|
248
|
+
### Playlist Controls
|
|
249
|
+
```
|
|
250
|
+
player.nextVideo(); // Next Video
|
|
251
|
+
player.prevVideo(); // Previous Video
|
|
252
|
+
player.goToPlaylistIndex(2); // Go to the specific video
|
|
253
|
+
player.getPlaylistInfo(); // Info Playlist
|
|
254
|
+
player.setPlaylistOptions({loop:true}); // Playlist Options
|
|
255
|
+
```
|
|
256
|
+
### Resolution Controls
|
|
257
|
+
```
|
|
258
|
+
player.setResolution("4:3"); // Change to 4:3
|
|
259
|
+
player.setResolution("16:9"); // Change to 16:9
|
|
260
|
+
player.setResolution("stretched"); // Change to stretched: Stretch the video to the entire container
|
|
261
|
+
player.setResolution("fit-to-screen"); // Change to fit to screen: It fits the screen, can cut parts of the video
|
|
262
|
+
player.setResolution("scale-to-fit"); // Intelligently fit to screen without cut video parts
|
|
263
|
+
console.log(player.getCurrentResolution()); // Get current resolution
|
|
264
|
+
```
|
|
265
|
+
## API Events
|
|
266
|
+
The MYETV Video Player includes a comprehensive custom event system that allows you to monitor all player state changes in real-time.
|
|
267
|
+
### on played
|
|
268
|
+
Description: Triggered when the video starts playing
|
|
269
|
+
When: User presses play or video starts automatically
|
|
270
|
+
```
|
|
271
|
+
player.addEventListener('played', (event) => {
|
|
272
|
+
console.log('Video started!', {
|
|
273
|
+
currentTime: event.currentTime,
|
|
274
|
+
duration: event.duration
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
### on paused
|
|
279
|
+
Description: Triggered when the video is pause
|
|
280
|
+
When: User presses pause or video stops
|
|
281
|
+
```
|
|
282
|
+
player.addEventListener('paused', (event) => {
|
|
283
|
+
console.log('Video paused at:', event.currentTime + 's');
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
### on ended
|
|
287
|
+
Description: Triggered when the video is ended
|
|
288
|
+
When: Video is ended
|
|
289
|
+
```
|
|
290
|
+
player.addEventListener('ended', (e) => {
|
|
291
|
+
console.log('Video terminato!', e.currentTime, e.duration, e.playlistInfo);
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
### on subtitle change
|
|
295
|
+
Description: Triggered when subtitles are enabled/disabled or track changes
|
|
296
|
+
When: User toggles subtitles or switches subtitle tracks
|
|
297
|
+
```
|
|
298
|
+
player.addEventListener('subtitlechange', (event) => {
|
|
299
|
+
if (event.enabled) {
|
|
300
|
+
console.log('Subtitles enabled:', event.trackLabel);
|
|
301
|
+
} else {
|
|
302
|
+
console.log('Subtitles disabled');
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
### on chapters change
|
|
307
|
+
Description: Triggered when chapters are changes
|
|
308
|
+
When: User switches chapters tracks
|
|
309
|
+
```
|
|
310
|
+
player.on('chapterchange', (data) => {
|
|
311
|
+
console.log('Chapter changed:', data.chapter.title);
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
### on pip change
|
|
315
|
+
Description: Triggered when Picture-in-Picture mode changes
|
|
316
|
+
When: Video enters or exits PiP mode
|
|
317
|
+
```
|
|
318
|
+
player.addEventListener('pipchange', (event) => {
|
|
319
|
+
console.log('Picture-in-Picture:', event.active ? 'Activated' : 'Deactivated');
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
### on fullscreen change
|
|
323
|
+
Description: Triggered when fullscreen mode changes
|
|
324
|
+
When: Player enters or exits fullscreen mode
|
|
325
|
+
```
|
|
326
|
+
player.addEventListener('fullscreenchange', (event) => {
|
|
327
|
+
console.log('Fullscreen:', event.active ? 'Activated' : 'Deactivated');
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
### on speed change
|
|
331
|
+
Description: Triggered when playback speed changes
|
|
332
|
+
When: User modifies playback speed (0.5x, 1x, 1.5x, 2x, etc.)
|
|
333
|
+
```
|
|
334
|
+
player.addEventListener('speedchange', (event) => {
|
|
335
|
+
console.log('Speed changed to:', event.speed + 'x');
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
### on time update
|
|
339
|
+
Description: Triggered during playback to update progress
|
|
340
|
+
When: Every 250ms during playback (throttled for performance)
|
|
341
|
+
```
|
|
342
|
+
player.addEventListener('timeupdate', (event) => {
|
|
343
|
+
console.log('Progress:', event.progress.toFixed(1) + '%');
|
|
344
|
+
// Update custom progress bar
|
|
345
|
+
updateProgressBar(event.progress);
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
### on volumechange
|
|
349
|
+
Description: Triggered when volume or mute state changes
|
|
350
|
+
When: User modifies volume or toggles mute
|
|
351
|
+
```
|
|
352
|
+
player.addEventListener('volumechange', (event) => {
|
|
353
|
+
if (event.muted) {
|
|
354
|
+
console.log('Audio muted');
|
|
355
|
+
} else {
|
|
356
|
+
console.log('Volume:', Math.round(event.volume * 100) + '%');
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
### Playlist API
|
|
361
|
+
```
|
|
362
|
+
player.addEventListener('playlistchange', (e) => {
|
|
363
|
+
console.log(`From "${e.fromTitle}" to "${e.toTitle}"`);
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
### Main APIs
|
|
367
|
+
getEventData()
|
|
368
|
+
Returns all requested state data in a single object:
|
|
369
|
+
```
|
|
370
|
+
const state = player.getEventData();
|
|
371
|
+
console.log(state);
|
|
372
|
+
/* Output:
|
|
373
|
+
{
|
|
374
|
+
played: true,
|
|
375
|
+
paused: false,
|
|
376
|
+
subtitleEnabled: false,
|
|
377
|
+
pipMode: false,
|
|
378
|
+
fullscreenMode: false,
|
|
379
|
+
speed: 1,
|
|
380
|
+
controlBarLength: 45.23,
|
|
381
|
+
volumeIsMuted: false,
|
|
382
|
+
duration: 3600,
|
|
383
|
+
volume: 0.8,
|
|
384
|
+
quality: "1080p",
|
|
385
|
+
buffered: 120.5
|
|
386
|
+
}
|
|
387
|
+
*/
|
|
388
|
+
```
|
|
389
|
+
### Event Listener Management
|
|
390
|
+
```
|
|
391
|
+
// Add listener
|
|
392
|
+
player.addEventListener('played', callback);
|
|
393
|
+
|
|
394
|
+
// Remove listener
|
|
395
|
+
player.removeEventListener('played', callback);
|
|
396
|
+
|
|
397
|
+
// Complete example
|
|
398
|
+
const onVideoPlay = (event) => {
|
|
399
|
+
console.log('Video started!', event.currentTime);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
player.addEventListener('played', onVideoPlay);
|
|
403
|
+
// ... later
|
|
404
|
+
player.removeEventListener('played', onVideoPlay);
|
|
405
|
+
```
|
|
406
|
+
### Complete Example
|
|
407
|
+
```
|
|
408
|
+
// Initialize the player
|
|
409
|
+
const player = new MYETVvideoplayer('myVideo', {
|
|
410
|
+
debug: true,
|
|
411
|
+
autoplay: false
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Monitor all main events
|
|
415
|
+
player.addEventListener('played', (e) => {
|
|
416
|
+
updateUI('playing', e.currentTime);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
player.addEventListener('paused', (e) => {
|
|
420
|
+
updateUI('paused', e.currentTime);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
player.addEventListener('timeupdate', (e) => {
|
|
424
|
+
document.getElementById('progress').textContent =
|
|
425
|
+
`${e.currentTime.toFixed(0)}s / ${e.duration.toFixed(0)}s`;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
player.addEventListener('volumechange', (e) => {
|
|
429
|
+
document.getElementById('volume-indicator').textContent =
|
|
430
|
+
e.muted ? 'muted' : `volume: ${Math.round(e.volume * 100)}%`;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Helper function to update UI
|
|
434
|
+
function updateUI(state, time) {
|
|
435
|
+
document.getElementById('player-status').textContent =
|
|
436
|
+
`Status: ${state} at ${time.toFixed(1)}s`;
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
### Technical Notes
|
|
440
|
+
Performance: The timeupdate event is throttled to 250ms to avoid overload
|
|
441
|
+
|
|
442
|
+
Compatibility: All events maintain compatibility with existing code
|
|
443
|
+
|
|
444
|
+
Debug: Enable debug: true in options to see event logs
|
|
445
|
+
|
|
446
|
+
Error Handling: Errors in callbacks don't interrupt the player
|
|
447
|
+
|
|
448
|
+
### Event Data Reference
|
|
449
|
+
|
|
450
|
+
| Property | Type | Description |
|
|
451
|
+
|:---------|:----:|:------------|
|
|
452
|
+
| `played` | `boolean` | Video is currently playing |
|
|
453
|
+
| `paused` | `boolean` | Video is currently paused |
|
|
454
|
+
| `subtitleEnabled` | `boolean` | Subtitles are enabled |
|
|
455
|
+
| `pipMode` | `boolean` | Picture-in-Picture is active |
|
|
456
|
+
| `fullscreenMode` | `boolean` | Fullscreen mode is active |
|
|
457
|
+
| `speed` | `number` | Current playback speed |
|
|
458
|
+
| `controlBarLength` | `number` | Current video time in seconds |
|
|
459
|
+
| `volumeIsMuted` | `boolean` | Audio is muted |
|
|
460
|
+
| `duration` | `number` | Total video duration |
|
|
461
|
+
| `volume` | `number` | Volume level (0-1) |
|
|
462
|
+
| `quality` | `string` | Current video quality |
|
|
463
|
+
| `buffered` | `number` | Buffered time in seconds |
|
|
464
|
+
|
|
465
|
+
## Keyboard Controls
|
|
466
|
+
|
|
467
|
+
| Key | Action |
|
|
468
|
+
|-----|--------|
|
|
469
|
+
| `Space` | Play/Pause |
|
|
470
|
+
| `M` | Mute/Unmute |
|
|
471
|
+
| `F` | Fullscreen |
|
|
472
|
+
| `P` | Picture-in-Picture |
|
|
473
|
+
| `S` | Toggle subtitles |
|
|
474
|
+
| `T` | Toggle title overlay |
|
|
475
|
+
| `N` | Next video in playlist |
|
|
476
|
+
| `P` | Previous video in playlist |
|
|
477
|
+
| `D` | Enable/disable debug |
|
|
478
|
+
| `←` | Backward 10 seconds |
|
|
479
|
+
| `→` | Forward 10 seconds |
|
|
480
|
+
| `↑` | Increase volume |
|
|
481
|
+
| `↓` | Decrease volume |
|
|
482
|
+
|
|
483
|
+
## CSS Customization
|
|
484
|
+
|
|
485
|
+
The MYETV Video Player is fully customizable using CSS variables and themes. The player includes a comprehensive set of CSS custom properties that allow you to modify colors, sizes, spacing, and animations without touching the core stylesheet.
|
|
486
|
+
|
|
487
|
+
### CSS Variables
|
|
488
|
+
|
|
489
|
+
The player uses CSS custom properties (variables) for easy theming:
|
|
490
|
+
```
|
|
491
|
+
.video-wrapper {
|
|
492
|
+
/* Primary Colors */
|
|
493
|
+
--player-primary-color: goldenrod;
|
|
494
|
+
--player-primary-hover: #daa520;
|
|
495
|
+
--player-primary-dark: #b8860b;
|
|
496
|
+
/* Control Colors */
|
|
497
|
+
--player-button-color: white;
|
|
498
|
+
--player-button-hover: rgba(255, 255, 255, 0.1);
|
|
499
|
+
--player-button-active: rgba(255, 255, 255, 0.2);
|
|
500
|
+
|
|
501
|
+
/* Text Colors */
|
|
502
|
+
--player-text-color: white;
|
|
503
|
+
--player-text-secondary: rgba(255, 255, 255, 0.8);
|
|
504
|
+
|
|
505
|
+
/* Background Colors */
|
|
506
|
+
--player-bg-primary: #000;
|
|
507
|
+
--player-bg-controls: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.8) 100%);
|
|
508
|
+
--player-bg-title-overlay: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
|
509
|
+
--player-bg-menu: rgba(20, 20, 20, 0.95);
|
|
510
|
+
|
|
511
|
+
/* Dimensions */
|
|
512
|
+
--player-border-radius: 12px;
|
|
513
|
+
--player-progress-height: 6px;
|
|
514
|
+
--player-volume-height: 4px;
|
|
515
|
+
--player-icon-size: 20px;
|
|
516
|
+
|
|
517
|
+
/* Transitions */
|
|
518
|
+
--player-transition-fast: 0.2s ease;
|
|
519
|
+
--player-transition-normal: 0.3s ease;
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
### Pre-built Themes
|
|
523
|
+
|
|
524
|
+
The player includes several pre-built themes that you can apply:
|
|
525
|
+
|
|
526
|
+
#### Blue Theme
|
|
527
|
+
```
|
|
528
|
+
.video-wrapper.player-theme-blue {
|
|
529
|
+
/* Automatically uses blue color scheme */
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
#### Green Theme
|
|
533
|
+
```
|
|
534
|
+
.video-wrapper.player-theme-green {
|
|
535
|
+
/* Automatically uses green color scheme */
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
#### Red Theme
|
|
539
|
+
```
|
|
540
|
+
.video-wrapper.player-theme-red {
|
|
541
|
+
/* Automatically uses red color scheme */
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
#### Dark Theme
|
|
545
|
+
```
|
|
546
|
+
.video-wrapper.player-theme-dark {
|
|
547
|
+
/* Enhanced dark mode with improved contrast */
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
### Control Size Variants
|
|
551
|
+
|
|
552
|
+
#### Large Controls
|
|
553
|
+
```
|
|
554
|
+
.video-wrapper.player-large-controls {
|
|
555
|
+
/* Bigger buttons and controls for better accessibility */
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
#### Compact Controls
|
|
559
|
+
```
|
|
560
|
+
.video-wrapper.player-compact-controls {
|
|
561
|
+
/* Smaller, space-efficient controls */
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
### Custom Theme Examples
|
|
565
|
+
|
|
566
|
+
#### Custom Purple Theme
|
|
567
|
+
```
|
|
568
|
+
.video-wrapper.my-purple-theme {
|
|
569
|
+
--player-primary-color: #9c27b0;
|
|
570
|
+
--player-primary-hover: #7b1fa2;
|
|
571
|
+
--player-primary-dark: #6a1b9a;
|
|
572
|
+
--player-bg-primary: #1a0d1a;
|
|
573
|
+
--player-bg-controls: linear-gradient(180deg, transparent 0%, rgba(26, 13, 26, 0.9) 100%);
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
#### High Contrast Theme
|
|
577
|
+
```
|
|
578
|
+
.video-wrapper.high-contrast-theme {
|
|
579
|
+
--player-primary-color: #ffff00;
|
|
580
|
+
--player-primary-hover: #ffeb3b;
|
|
581
|
+
--player-text-color: #ffffff;
|
|
582
|
+
--player-bg-primary: #000000;
|
|
583
|
+
--player-bg-controls: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.95) 100%);
|
|
584
|
+
--player-border-radius: 0; /* Sharp corners for accessibility */
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
#### Minimal Theme
|
|
588
|
+
```
|
|
589
|
+
.video-wrapper.minimal-theme {
|
|
590
|
+
--player-bg-controls: rgba(0, 0, 0, 0.3);
|
|
591
|
+
--player-bg-title-overlay: rgba(0, 0, 0, 0.3);
|
|
592
|
+
--player-border-radius: 0;
|
|
593
|
+
--player-progress-height: 3px;
|
|
594
|
+
--player-volume-height: 2px;
|
|
595
|
+
--player-button-padding: 4px;
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
### Responsive Customization
|
|
599
|
+
|
|
600
|
+
The player automatically adapts to different screen sizes. You can customize the responsive behavior:
|
|
601
|
+
```
|
|
602
|
+
/* Custom mobile adjustments /
|
|
603
|
+
@media (max-width: 768px) {
|
|
604
|
+
.video-wrapper {
|
|
605
|
+
--player-icon-size: 18px;
|
|
606
|
+
--player-progress-height: 8px; / Thicker for touch /
|
|
607
|
+
--player-button-padding: 12px; / Larger touch targets */
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
@media (max-width: 480px) {
|
|
612
|
+
.video-wrapper {
|
|
613
|
+
--player-controls-padding: 12px 8px 8px;
|
|
614
|
+
--player-border-radius: 0; /* Full width on small screens */
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
### Custom Subtitle Styling
|
|
619
|
+
|
|
620
|
+
Customize the appearance of subtitles:
|
|
621
|
+
```
|
|
622
|
+
.video-player::cue {
|
|
623
|
+
background: rgba(0, 0, 0, 0.9);
|
|
624
|
+
color: #ffffff;
|
|
625
|
+
font-size: 18px;
|
|
626
|
+
font-family: 'Your Custom Font', sans-serif;
|
|
627
|
+
padding: 10px 15px;
|
|
628
|
+
border-radius: 8px;
|
|
629
|
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/* Highlighted subtitle text */
|
|
633
|
+
.video-player::cue(.highlight) {
|
|
634
|
+
background: var(--player-primary-color);
|
|
635
|
+
color: black;
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
### Animation Customization
|
|
639
|
+
|
|
640
|
+
Control the player's animations:
|
|
641
|
+
```
|
|
642
|
+
.video-wrapper {
|
|
643
|
+
/* Faster animations */
|
|
644
|
+
--player-transition-fast: 0.1s ease;
|
|
645
|
+
--player-transition-normal: 0.15s ease;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/* Disable animations (accessibility) */
|
|
649
|
+
.video-wrapper.no-animations {
|
|
650
|
+
--player-transition-fast: 0s;
|
|
651
|
+
--player-transition-normal: 0s;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.video-wrapper.no-animations * {
|
|
655
|
+
transition: none !important;
|
|
656
|
+
animation: none !important;
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
### Quality Selector Customization
|
|
660
|
+
|
|
661
|
+
The dual-quality indicator can be customized:
|
|
662
|
+
```
|
|
663
|
+
.quality-btn {
|
|
664
|
+
min-height: 40px; /* More space for two lines */
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.selected-quality {
|
|
668
|
+
font-size: 16px; /* Larger selected quality text */
|
|
669
|
+
font-weight: 600;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.current-quality {
|
|
673
|
+
font-size: 11px; /* Current playing quality */
|
|
674
|
+
opacity: 0.7;
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
### Usage Examples
|
|
678
|
+
|
|
679
|
+
#### Apply Theme via JavaScript
|
|
680
|
+
```
|
|
681
|
+
// Apply theme when initializing
|
|
682
|
+
const player = new MYETVvideoplayer('video', {
|
|
683
|
+
// ... other options
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Add theme class
|
|
687
|
+
document.querySelector('.video-wrapper').classList.add('player-theme-blue');
|
|
688
|
+
```
|
|
689
|
+
#### Apply Theme via HTML
|
|
690
|
+
```<div class="video-wrapper player-theme-dark player-large-controls"> <video id="my-video"> <!-- video sources --> </video> </div> ```
|
|
691
|
+
|
|
692
|
+
#### Dynamic Theme Switching
|
|
693
|
+
```
|
|
694
|
+
function switchTheme(themeName) {
|
|
695
|
+
const wrapper = document.querySelector('.video-wrapper');
|
|
696
|
+
|
|
697
|
+
// Remove existing theme classes
|
|
698
|
+
wrapper.className = wrapper.className.replace(/player-theme-\w+/g, '');
|
|
699
|
+
|
|
700
|
+
// Add new theme
|
|
701
|
+
if (themeName !== 'default') {
|
|
702
|
+
wrapper.classList.add(`player-theme-${themeName}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Usage
|
|
707
|
+
switchTheme('blue'); // Switch to blue theme
|
|
708
|
+
switchTheme('dark'); // Switch to dark theme
|
|
709
|
+
switchTheme('default'); // Switch to default theme
|
|
710
|
+
```
|
|
711
|
+
## Browser Compatibility
|
|
712
|
+
The CSS uses modern features with fallbacks:
|
|
713
|
+
|
|
714
|
+
CSS Custom Properties: Supported in all modern browsers
|
|
715
|
+
|
|
716
|
+
CSS Grid/Flexbox: Full support in Chrome 60+, Firefox 55+, Safari 11+
|
|
717
|
+
|
|
718
|
+
Backdrop Filter: Enhanced blur effects where supported
|
|
719
|
+
|
|
720
|
+
CSS Variables: Graceful fallback to default values
|
|
721
|
+
|
|
722
|
+
Performance Tips
|
|
723
|
+
Use transform and opacity for animations (GPU accelerated)
|
|
724
|
+
|
|
725
|
+
CSS variables are cached by the browser for better performance
|
|
726
|
+
|
|
727
|
+
Minimal DOM manipulation thanks to CSS-based theming
|
|
728
|
+
|
|
729
|
+
Hardware-accelerated transitions for smooth playback
|
|
730
|
+
|
|
731
|
+
## Plguins feature
|
|
732
|
+
The player supports custom plugins to extend its functionality. Every plugins must have its own documentation to clearly known how to use it. Plugins are modular so you can add or remove any plugins whenever you want. This is just an example based on two plugins.
|
|
733
|
+
### Add a plguin to the player
|
|
734
|
+
```
|
|
735
|
+
<!-- Google Analytics 4 -->
|
|
736
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
|
|
737
|
+
<script>
|
|
738
|
+
window.dataLayer = window.dataLayer || [];
|
|
739
|
+
function gtag(){dataLayer.push(arguments);}
|
|
740
|
+
gtag('js', new Date());
|
|
741
|
+
gtag('config', 'G-XXXXXXXXXX');
|
|
742
|
+
</script>
|
|
743
|
+
<!-- Player bundle -->
|
|
744
|
+
<script src="dist/myetv-player.min.js"></script>
|
|
745
|
+
<!-- Plugin bundle -->
|
|
746
|
+
<script src="plugins/youtube/myetv-player-youtube-plugin.js"></script>
|
|
747
|
+
<script src="plugins/google-analytics/myetv-player-g-analytics-plugin.js"></script>
|
|
748
|
+
```
|
|
749
|
+
### Initialization exmples with plugins
|
|
750
|
+
```
|
|
751
|
+
const player = new MYETVPlayer('my-video', {
|
|
752
|
+
debug: true,
|
|
753
|
+
plugins: {
|
|
754
|
+
youtube: {
|
|
755
|
+
videoId: 'dQw4w9WgXcQ', // Video ID of YouTube (example)
|
|
756
|
+
apiKey: 'your-api-key',
|
|
757
|
+
autoplay: false
|
|
758
|
+
},
|
|
759
|
+
analytics: {
|
|
760
|
+
platform: 'ga4',
|
|
761
|
+
videoTitle: 'My Awesome Video',
|
|
762
|
+
videoCategory: 'Tutorial',
|
|
763
|
+
videoId: 'video-001'
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
## Chapters feature
|
|
770
|
+
Supports flexible time formats (HH:MM:SS, MM:SS, or seconds) and images url (optional)
|
|
771
|
+
### JSON format
|
|
772
|
+
```
|
|
773
|
+
const player = new VideoPlayer('myVideo', {
|
|
774
|
+
chapters: [
|
|
775
|
+
{
|
|
776
|
+
time: 0,
|
|
777
|
+
title: "Introduction",
|
|
778
|
+
image: "https://example.com/intro.jpg"
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
time: 120,
|
|
782
|
+
title: "Main Content",
|
|
783
|
+
image: "https://example.com/main.jpg",
|
|
784
|
+
color: "#FF5722" // Custom color (optional)
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
time: '8:30',
|
|
788
|
+
title: "Conclusion"
|
|
789
|
+
// No image (optional)
|
|
790
|
+
}
|
|
791
|
+
]
|
|
792
|
+
});
|
|
793
|
+
```
|
|
794
|
+
### String format
|
|
795
|
+
```
|
|
796
|
+
const player = new VideoPlayer('myVideo', {
|
|
797
|
+
chapters: "0:00:00|Introduction|intro.jpg,0:02:00|Main Content|main.jpg,0:05:00|Conclusion"
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
## Playlist feature
|
|
801
|
+
### Playlist Detection System
|
|
802
|
+
The playlist detection will work through HTML attributes on your video elements:
|
|
803
|
+
```
|
|
804
|
+
<!-- Example playlist setup -->
|
|
805
|
+
<video id="myVideo" class="video-player"
|
|
806
|
+
data-playlist-id="my-series"
|
|
807
|
+
data-playlist-index="0">
|
|
808
|
+
<source src="video1-720p.mp4" type="video/mp4" data-quality="720p">
|
|
809
|
+
<source src="video1-480p.mp4" type="video/mp4" data-quality="480p">
|
|
810
|
+
</video>
|
|
811
|
+
|
|
812
|
+
<!-- Next video in playlist -->
|
|
813
|
+
<video id="video2" class="video-player"
|
|
814
|
+
data-playlist-id="my-series"
|
|
815
|
+
data-playlist-index="1">
|
|
816
|
+
<source src="video2-720p.mp4" type="video/mp4" data-quality="720p">
|
|
817
|
+
<source src="video2-480p.mp4" type="video/mp4" data-quality="480p">
|
|
818
|
+
</video>
|
|
819
|
+
```
|
|
820
|
+
## Adaptive streaming (HLS/DASH)
|
|
821
|
+
### Adaptive Streaming APIs
|
|
822
|
+
```
|
|
823
|
+
// Info adaptive streaming
|
|
824
|
+
player.getAdaptiveStreamingInfo();
|
|
825
|
+
|
|
826
|
+
// Change quality of adaptive streaming
|
|
827
|
+
player.setAdaptiveQuality(1); // Specify quality
|
|
828
|
+
player.setAdaptiveQuality('auto'); // Auto-switching
|
|
829
|
+
```
|
|
830
|
+
### Example Playlist+Adaptive Streaming
|
|
831
|
+
```
|
|
832
|
+
<!-- Video 1: DASH -->
|
|
833
|
+
<video data-playlist-id="series" data-playlist-index="0" src="ep1.mpd">
|
|
834
|
+
|
|
835
|
+
<!-- Video 2: HLS -->
|
|
836
|
+
<video data-playlist-id="series" data-playlist-index="1" src="ep2.m3u8">
|
|
837
|
+
|
|
838
|
+
<!-- Video 3: Traditional -->
|
|
839
|
+
<video data-playlist-id="series" data-playlist-index="2">
|
|
840
|
+
<source src="ep3-1080p.mp4" data-quality="1080p">
|
|
841
|
+
<source src="ep3-720p.mp4" data-quality="720p">
|
|
842
|
+
</video>
|
|
843
|
+
```
|
|
844
|
+
## Supported Browsers
|
|
845
|
+
Chrome 60+
|
|
846
|
+
Firefox 55+
|
|
847
|
+
Safari 11+
|
|
848
|
+
Edge 79+
|
|
849
|
+
Mobile browsers with HTML5 support
|
|
850
|
+
|
|
851
|
+
## License
|
|
852
|
+
This project is released under the MIT License.
|
|
853
|
+
|
|
854
|
+
## Contributing
|
|
855
|
+
Fork the repository
|
|
856
|
+
|
|
857
|
+
Create a feature branch (git checkout -b feature/feature-name)
|
|
858
|
+
|
|
859
|
+
Commit your changes (git commit -am 'Add feature')
|
|
860
|
+
|
|
861
|
+
Push to the branch (git push origin feature/feature-name)
|
|
862
|
+
|
|
863
|
+
Create a Pull Request
|
|
864
|
+
|
|
865
|
+
## Bug Reports
|
|
866
|
+
To report bugs or request features, open an issue in the repository.
|